granttap-mcp 0.8.2 → 0.8.4
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 +79 -6
- package/apps/bridge/src/attachment-store.ts +89 -0
- package/apps/bridge/src/config/capability-policy.ts +36 -1
- package/apps/bridge/src/config/runtime.ts +15 -7
- package/apps/bridge/src/machine-load/disk-usage.ts +144 -0
- package/apps/bridge/src/machine-load/index.ts +31 -1
- package/apps/bridge/src/machine-load/process-sampler.ts +317 -23
- package/apps/bridge/src/mesh/README.md +5 -0
- package/apps/bridge/src/mesh/call-scope.ts +13 -1
- package/apps/bridge/src/mesh/capability.ts +8 -2
- package/apps/bridge/src/mesh/catalog.ts +1 -0
- package/apps/bridge/src/mesh/checkpoint.ts +32 -6
- package/apps/bridge/src/mesh/journal.ts +10 -3
- package/apps/bridge/src/mesh/map.ts +53 -12
- package/apps/bridge/src/mesh/prompt-context.ts +51 -27
- package/apps/bridge/src/mesh/remote.ts +55 -0
- package/apps/bridge/src/mesh/runtime-dependencies.ts +4 -0
- package/apps/bridge/src/mesh/runtime-handoff.ts +9 -1
- package/apps/bridge/src/mesh/runtime.ts +32 -4
- package/apps/bridge/src/mesh/store-state.ts +82 -25
- package/apps/bridge/src/mesh/store-sync.ts +189 -0
- package/apps/bridge/src/mesh/store.ts +93 -8
- package/apps/bridge/src/monitor.ts +141 -11
- package/apps/bridge/src/policy/capability-fingerprint.ts +33 -7
- package/apps/bridge/src/policy/effective-action.ts +26 -3
- package/apps/bridge/src/policy/governed-projects.ts +66 -0
- package/apps/bridge/src/project-policy/mapping.ts +22 -5
- package/apps/bridge/src/project-policy/runtime.ts +66 -6
- package/apps/bridge/src/reply/process.ts +54 -13
- package/apps/bridge/src/reply/provider-headless.ts +2 -0
- package/apps/bridge/src/reply/types.ts +2 -0
- package/apps/bridge/src/reply.ts +14 -3
- package/apps/bridge/src/sessions/activity-helpers.ts +14 -0
- package/apps/bridge/src/sessions/claude.ts +58 -1
- package/apps/bridge/src/sessions/codex.ts +9 -0
- package/apps/bridge/src/sessions/edit-stats.ts +220 -0
- package/apps/bridge/src/sessions/telemetry/command-preview.ts +13 -3
- package/apps/bridge/src/sessions/telemetry.ts +59 -5
- package/apps/bridge/src/sessions.ts +25 -0
- package/apps/mcp/src/mcp-tools/interaction.ts +101 -24
- package/apps/mcp/src/mcp-tools/mesh-resource.ts +59 -12
- package/apps/mcp/src/mcp-tools/relay.ts +43 -3
- package/apps/mcp/src/mesh-stdio.ts +4 -1
- package/apps/mcp/src/server.ts +27 -2
- package/package.json +1 -1
- package/packages/protocol/messages/capabilities.ts +7 -0
- package/packages/protocol/messages/interaction.ts +28 -0
- package/packages/protocol/messages/machine.ts +51 -0
- package/packages/protocol/messages/mesh.ts +10 -0
- package/packages/protocol/messages/project-policy.ts +36 -1
- package/packages/protocol/messages/sessions.ts +34 -0
- package/packages/protocol/schema.ts +8 -0
package/README.md
CHANGED
|
@@ -82,7 +82,25 @@ committed state and leaving that work behind. Asked to checkpoint from the
|
|
|
82
82
|
phone, the source computer commits everything to `granttap/checkpoint/<task>`
|
|
83
83
|
from a temporary index, so HEAD, the current branch, and the working tree stay
|
|
84
84
|
exactly as the agent left them, and the capsule carries that commit. Nothing is
|
|
85
|
-
pushed
|
|
85
|
+
pushed unless the phone asks for it per handoff: then the source publishes the
|
|
86
|
+
branch the capsule names to the checkout's remote — never by force — before the
|
|
87
|
+
capsule leaves, a push that fails blocks the move, and a destination that lacks
|
|
88
|
+
the commit fetches that branch once before refusing. A handoff can also stay on
|
|
89
|
+
one computer: the same Task continues with another agent there, in a worktree
|
|
90
|
+
of its own from the same commit, taken up at once by the computer that
|
|
91
|
+
prepared it.
|
|
92
|
+
|
|
93
|
+
### Pause and resume
|
|
94
|
+
|
|
95
|
+
A chat can be held from the phone. The hold is enforced where the work
|
|
96
|
+
happens: every provider hook refuses every tool call from that chat — the
|
|
97
|
+
agent reads "GrantTap paused this chat from the phone" and is told to wait —
|
|
98
|
+
and a delivery already running for the chat is stopped. A message sent to a
|
|
99
|
+
held chat is refused for the same reason. Resuming lifts the hold; asked to
|
|
100
|
+
continue, the computer answers the phone at once and then delivers one
|
|
101
|
+
continuation prompt in the background, so "start" is one tap. The hold lives
|
|
102
|
+
in the runtime config as `pausedSessions` and is published on the session as
|
|
103
|
+
`paused`.
|
|
86
104
|
|
|
87
105
|
Claims do not wait for an agent to announce them. Every edit an agent makes is
|
|
88
106
|
visible in its transcript, so the runtime derives an intent claim from each
|
|
@@ -205,6 +223,37 @@ The bounded encrypted protocol preserves:
|
|
|
205
223
|
An optional bounded `errorClass` may describe an error category. Full tool
|
|
206
224
|
error payloads are not copied into usage telemetry by default.
|
|
207
225
|
|
|
226
|
+
A shell call is named by the command it ran — `npm`, `git`, `xcodebuild` —
|
|
227
|
+
with the tool that ran it kept beside the name, so the usage screen can say
|
|
228
|
+
which tool was slow or failing rather than listing every call as Bash.
|
|
229
|
+
|
|
230
|
+
Machine load attributes to an agent everything the agent started — its
|
|
231
|
+
shells, its node workers, the build a shell ran — found through the process
|
|
232
|
+
tree, and names the heaviest kinds of process it runs (`node` ×19, `zsh` ×3)
|
|
233
|
+
so the phone can say what an agent is doing, not only that it is. The
|
|
234
|
+
executable path and the command line are read separately and joined by pid,
|
|
235
|
+
because a path with a space in it (`~/Library/Application Support/Claude/…`)
|
|
236
|
+
cannot be recovered from a command line split on whitespace.
|
|
237
|
+
|
|
238
|
+
The number can be opened. Each agent's sample carries its heaviest forty
|
|
239
|
+
processes one by one — pid, name, CPU, memory, and what the process was asked
|
|
240
|
+
to do with the executable's own path and any secret removed — and the same
|
|
241
|
+
load added up by chat. Claude Code hands every MCP server it starts the chat's
|
|
242
|
+
id in `CLAUDE_CODE_SESSION_ID`, and `ps -E` shows a process's environment to
|
|
243
|
+
its owner, so an agent's root process is named by its descendants; only that
|
|
244
|
+
one variable is read, the answer is remembered for the root's lifetime, and a
|
|
245
|
+
root that could not be named is asked about again after a minute. Each agent's
|
|
246
|
+
own folders (`~/.claude`, `~/.codex`, `~/.cursor`, `~/.grok`, plus Claude's
|
|
247
|
+
CLI cache) are measured with `du` in the background every ten minutes, top-level
|
|
248
|
+
child by child, and carried on the sample; a sample never waits for `du`. The
|
|
249
|
+
helper log says once in five minutes what each agent weighed, so a wrong
|
|
250
|
+
number on the phone can be traced to the computer.
|
|
251
|
+
|
|
252
|
+
A shell call is fingerprinted for Project policy by the command it runs —
|
|
253
|
+
`git`, `rm`, `npm` — so a Project can allow one command and ask about another
|
|
254
|
+
instead of deciding about the shell as a whole; a line with no command word
|
|
255
|
+
is still plain `Shell`.
|
|
256
|
+
|
|
208
257
|
### One computer, whatever the network calls it
|
|
209
258
|
|
|
210
259
|
The Mesh keys a computer by an identity written down once, on first use, in
|
|
@@ -229,11 +278,17 @@ Mesh show it. A `UserPromptSubmit` hook, installed beside the approval hook by
|
|
|
229
278
|
`granttap setup`, adds the unread journal to the next prompt of the live
|
|
230
279
|
session together with the Mesh brief — the other live Tasks in the Project,
|
|
231
280
|
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
|
-
|
|
281
|
+
question still unanswered — and names the MCP resource `granttap://mesh/map`,
|
|
282
|
+
one page of markdown with the whole Project: Tasks, who edits which module,
|
|
283
|
+
the other side of each repository, dependencies, and what just happened. That
|
|
284
|
+
resource is listed, because Claude Code reads only listed resources, and it is
|
|
285
|
+
scoped without a token: Claude Code starts one MCP server per chat and hands
|
|
286
|
+
it the chat's id in `CLAUDE_CODE_SESSION_ID`, which nothing said over the
|
|
287
|
+
connection can change. `granttap://mesh/current` serves the same chat's scoped
|
|
288
|
+
state there. Other providers keep the tokened `granttap://mesh/{capability}`
|
|
289
|
+
and `…/map` forms from an attributed `notify`. Background runs themselves
|
|
290
|
+
receive nothing from the hook; the journal is kept for the session a person
|
|
291
|
+
is in.
|
|
237
292
|
|
|
238
293
|
### Tool versions and updates from the phone
|
|
239
294
|
|
|
@@ -291,6 +346,24 @@ capability kind, so the phone shows what is actually in force rather than what
|
|
|
291
346
|
was sent. Revision zero is a Project with no policy yet, and it is reported so
|
|
292
347
|
the first policy can be written.
|
|
293
348
|
|
|
349
|
+
A refused edit is answered, not swallowed. When a computer cannot apply a
|
|
350
|
+
policy — the phone built it on a revision the computer no longer holds, the
|
|
351
|
+
engine is not running, the policy is invalid — it sends
|
|
352
|
+
`project.policy.rejected` with the reason and the revision it actually holds,
|
|
353
|
+
then the policy it holds, so the phone can say what happened, keep the edit,
|
|
354
|
+
and offer it again on top of the current revision. The helper log carries the
|
|
355
|
+
same line. What the engine leaves null is sent as absent: the phone reads a
|
|
356
|
+
status strictly, and a `null` where a field was optional was read as a wrong
|
|
357
|
+
value and the whole status dropped.
|
|
358
|
+
|
|
359
|
+
A shell call is fingerprinted by the command it runs (`git`, `rm`), a deploy or
|
|
360
|
+
network call by the phrase that made it one (`git push`, `curl`), so a named
|
|
361
|
+
rule can forbid pushing or deleting while the rest of the shell stays allowed.
|
|
362
|
+
A commit or pull request whose command carries a co-author or "generated with"
|
|
363
|
+
trailer is fingerprinted as `co-authorship`, its own row in the phone's Shell
|
|
364
|
+
table, so a Project whose history is authored by people can deny the trailer
|
|
365
|
+
and leave git alone.
|
|
366
|
+
|
|
294
367
|
Evaluation happens in the provider hook before the action runs, with a deadline
|
|
295
368
|
long enough for a busy machine to answer. A missed answer falls back to the
|
|
296
369
|
legacy GrantTap gate rather than to a silent allow, and content never crosses
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attachments that arrived ahead of their message.
|
|
3
|
+
*
|
|
4
|
+
* A photo is most of a message's bytes and all of its wait on a slow link:
|
|
5
|
+
* the phone sends it as soon as it is picked, sealed like everything else,
|
|
6
|
+
* and the message that follows names it instead of carrying it. Each one is
|
|
7
|
+
* kept on disk under its id, for the message or for two hours, whichever
|
|
8
|
+
* comes first, and read exactly once.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { UserAttachment, UserAttachmentUpload } from "../../../packages/protocol/schema";
|
|
13
|
+
import { configDir } from "./config";
|
|
14
|
+
|
|
15
|
+
export const ATTACHMENT_TTL_MS = 2 * 60 * 60_000;
|
|
16
|
+
|
|
17
|
+
function directory(): string {
|
|
18
|
+
const dir = join(configDir(), "attachments");
|
|
19
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
20
|
+
return dir;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function safeId(attachmentId: string): string | undefined {
|
|
24
|
+
const id = attachmentId.trim();
|
|
25
|
+
return /^[A-Za-z0-9_-]{1,180}$/.test(id) ? id : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Keep one attachment for the message that names it; drop the stale ones.
|
|
30
|
+
* The pairing room it came through is kept with it, so a message from
|
|
31
|
+
* another pairing cannot name it.
|
|
32
|
+
*/
|
|
33
|
+
export function storeAttachment(upload: UserAttachmentUpload, room?: string, now = Date.now()): boolean {
|
|
34
|
+
const id = safeId(upload.attachmentId);
|
|
35
|
+
if (!id) return false;
|
|
36
|
+
const dir = directory();
|
|
37
|
+
pruneAttachments(now, dir);
|
|
38
|
+
const record = {
|
|
39
|
+
name: upload.name, mimeType: upload.mimeType, data: upload.data, receivedAt: now,
|
|
40
|
+
...(room ? { room } : {}),
|
|
41
|
+
};
|
|
42
|
+
writeFileSync(join(dir, `${id}.json`), JSON.stringify(record), { mode: 0o600 });
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The attachment the message named, taken off disk; nothing when it never came. */
|
|
47
|
+
export function takeAttachment(attachmentId: string, room?: string, now = Date.now()): UserAttachment | undefined {
|
|
48
|
+
const id = safeId(attachmentId);
|
|
49
|
+
if (!id) return undefined;
|
|
50
|
+
const path = join(directory(), `${id}.json`);
|
|
51
|
+
try {
|
|
52
|
+
const record = JSON.parse(readFileSync(path, "utf8")) as {
|
|
53
|
+
name?: unknown; mimeType?: unknown; data?: unknown; receivedAt?: unknown; room?: unknown;
|
|
54
|
+
};
|
|
55
|
+
// Another pairing's attachment is left where it is, for its own message.
|
|
56
|
+
if (typeof record.room === "string" && room != null && record.room !== room) return undefined;
|
|
57
|
+
rmSync(path, { force: true });
|
|
58
|
+
if (typeof record.receivedAt === "number" && now - record.receivedAt > ATTACHMENT_TTL_MS) return undefined;
|
|
59
|
+
if (typeof record.name !== "string" || typeof record.mimeType !== "string" || typeof record.data !== "string") {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return { name: record.name, mimeType: record.mimeType, data: record.data };
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function pruneAttachments(now = Date.now(), dir = directory()): number {
|
|
69
|
+
let removed = 0;
|
|
70
|
+
let names: string[];
|
|
71
|
+
try {
|
|
72
|
+
names = readdirSync(dir);
|
|
73
|
+
} catch {
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
if (!name.endsWith(".json")) continue;
|
|
78
|
+
const path = join(dir, name);
|
|
79
|
+
try {
|
|
80
|
+
if (now - statSync(path).mtimeMs > ATTACHMENT_TTL_MS) {
|
|
81
|
+
rmSync(path, { force: true });
|
|
82
|
+
removed += 1;
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Gone already.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return removed;
|
|
89
|
+
}
|
|
@@ -52,11 +52,43 @@ export function setSessionShellAllowed(rawSessionId: string, allowed: boolean):
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export type SessionCapabilityBlock = {
|
|
55
|
-
kind: "mcp" | "skill" | "cli";
|
|
55
|
+
kind: "mcp" | "skill" | "cli" | "session";
|
|
56
56
|
name: string;
|
|
57
57
|
reason: string;
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
/** What the agent reads when its chat is held from the phone. */
|
|
61
|
+
export const PAUSED_SESSION_REASON =
|
|
62
|
+
"GrantTap paused this chat from the phone. Do not retry the call or work around it: stop and wait until the chat is resumed.";
|
|
63
|
+
|
|
64
|
+
export function setSessionPaused(rawSessionId: string, paused: boolean): void {
|
|
65
|
+
const sessionId = capabilitySessionId(rawSessionId);
|
|
66
|
+
if (!sessionId) throw new TypeError("invalid session pause toggle");
|
|
67
|
+
const runtime = loadRuntimeConfig();
|
|
68
|
+
const held = new Set(runtime.pausedSessions);
|
|
69
|
+
if (paused) held.add(sessionId);
|
|
70
|
+
else held.delete(sessionId);
|
|
71
|
+
runtime.pausedSessions = [...held].sort();
|
|
72
|
+
saveRuntimeConfig(runtime);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isSessionPaused(rawSessionId: string | null | undefined): boolean {
|
|
76
|
+
const sessionId = capabilitySessionId(rawSessionId);
|
|
77
|
+
return sessionId != null && loadRuntimeConfig().pausedSessions.includes(sessionId);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A held chat is refused every tool, whatever the tool: the hold is the
|
|
82
|
+
* person's, and no capability rule of the chat's own may outrank it.
|
|
83
|
+
*/
|
|
84
|
+
export function pausedSessionBlock(
|
|
85
|
+
rawSessionId: string | null | undefined,
|
|
86
|
+
): SessionCapabilityBlock | null {
|
|
87
|
+
return isSessionPaused(rawSessionId)
|
|
88
|
+
? { kind: "session", name: "paused", reason: PAUSED_SESSION_REASON }
|
|
89
|
+
: null;
|
|
90
|
+
}
|
|
91
|
+
|
|
60
92
|
function mcpBlock(server: string): SessionCapabilityBlock {
|
|
61
93
|
return {
|
|
62
94
|
kind: "mcp",
|
|
@@ -140,6 +172,9 @@ export function blockedSessionCapability(
|
|
|
140
172
|
const toolName = boundedIdentifier(rawToolName, 240);
|
|
141
173
|
if (!sessionId || !toolName) return null;
|
|
142
174
|
const runtime = loadRuntimeConfig();
|
|
175
|
+
if (runtime.pausedSessions.includes(sessionId)) {
|
|
176
|
+
return { kind: "session", name: "paused", reason: PAUSED_SESSION_REASON };
|
|
177
|
+
}
|
|
143
178
|
|
|
144
179
|
const server = mcpServerFromTool(toolName);
|
|
145
180
|
if (server && (runtime.sessionMcpDisabled[sessionId] ?? []).includes(server)) {
|
|
@@ -23,6 +23,8 @@ export type RuntimeConfig = {
|
|
|
23
23
|
sessionMcpDisabled: Record<string, string[]>;
|
|
24
24
|
sessionSkillsDisabled: Record<string, string[]>;
|
|
25
25
|
sessionShellDisabled: string[];
|
|
26
|
+
/** Chats held from the phone: every tool call is refused until resumed. */
|
|
27
|
+
pausedSessions: string[];
|
|
26
28
|
providerSettings: ProviderRuntimeSettings;
|
|
27
29
|
meshEnabled: boolean;
|
|
28
30
|
/** Absolute path to the separately distributed GrantTap Engine binary. */
|
|
@@ -41,6 +43,7 @@ const DEFAULT_RUNTIME: RuntimeConfig = {
|
|
|
41
43
|
sessionMcpDisabled: {},
|
|
42
44
|
sessionSkillsDisabled: {},
|
|
43
45
|
sessionShellDisabled: [],
|
|
46
|
+
pausedSessions: [],
|
|
44
47
|
providerSettings: { claude: true, codex: true, cursor: true, grok: true },
|
|
45
48
|
meshEnabled: true,
|
|
46
49
|
enginePath: null,
|
|
@@ -97,6 +100,15 @@ function parseDisabledCapabilities(raw: unknown): Record<string, string[]> {
|
|
|
97
100
|
return result;
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
function parseSessionList(raw: unknown): string[] {
|
|
104
|
+
if (!Array.isArray(raw)) return [];
|
|
105
|
+
return [...new Set<string>(
|
|
106
|
+
(raw as unknown[])
|
|
107
|
+
.map(capabilitySessionId)
|
|
108
|
+
.filter((sessionId): sessionId is string => sessionId != null),
|
|
109
|
+
)];
|
|
110
|
+
}
|
|
111
|
+
|
|
100
112
|
/** Only an absolute path can be verified, so a relative one is not kept. */
|
|
101
113
|
function parseEnginePath(raw: unknown): string | null {
|
|
102
114
|
return typeof raw === "string" && raw.startsWith("/") && raw.length <= 1024 ? raw : null;
|
|
@@ -134,13 +146,8 @@ export function loadRuntimeConfig(): RuntimeConfig {
|
|
|
134
146
|
autoAcceptPaused: raw.autoAcceptPaused === true,
|
|
135
147
|
sessionMcpDisabled: parseDisabledCapabilities(raw.sessionMcpDisabled),
|
|
136
148
|
sessionSkillsDisabled: parseDisabledCapabilities(raw.sessionSkillsDisabled),
|
|
137
|
-
sessionShellDisabled:
|
|
138
|
-
|
|
139
|
-
(raw.sessionShellDisabled as unknown[])
|
|
140
|
-
.map(capabilitySessionId)
|
|
141
|
-
.filter((sessionId): sessionId is string => sessionId != null),
|
|
142
|
-
)]
|
|
143
|
-
: [],
|
|
149
|
+
sessionShellDisabled: parseSessionList(raw.sessionShellDisabled),
|
|
150
|
+
pausedSessions: parseSessionList(raw.pausedSessions),
|
|
144
151
|
providerSettings: parseProviderSettings(raw.providerSettings),
|
|
145
152
|
meshEnabled: raw.meshEnabled !== false,
|
|
146
153
|
enginePath: parseEnginePath(raw.enginePath),
|
|
@@ -154,6 +161,7 @@ export function loadRuntimeConfig(): RuntimeConfig {
|
|
|
154
161
|
sessionMcpDisabled: {},
|
|
155
162
|
sessionSkillsDisabled: {},
|
|
156
163
|
sessionShellDisabled: [],
|
|
164
|
+
pausedSessions: [],
|
|
157
165
|
providerSettings: { ...DEFAULT_RUNTIME.providerSettings },
|
|
158
166
|
meshEnabled: true,
|
|
159
167
|
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each agent keeps on the disk.
|
|
3
|
+
*
|
|
4
|
+
* Every agent has a folder of its own — `~/.claude`, `~/.codex` — that grows
|
|
5
|
+
* with transcripts, caches and installed skills and that nothing on the phone
|
|
6
|
+
* could see. The numbers come from `du`, which is slow on a folder with a
|
|
7
|
+
* year of transcripts, so a measurement is kept for ten minutes and refreshed
|
|
8
|
+
* in the background: a load sample never waits for it.
|
|
9
|
+
*/
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
|
|
16
|
+
const run = promisify(execFile);
|
|
17
|
+
|
|
18
|
+
export type DiskUsageEntry = { path: string; bytes: number };
|
|
19
|
+
export type AgentDiskUsage = {
|
|
20
|
+
measuredAt: number;
|
|
21
|
+
totalBytes: number;
|
|
22
|
+
/** The heaviest places inside the agent's folders, heaviest first, then the rest as one. */
|
|
23
|
+
entries: DiskUsageEntry[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const DISK_TTL_MS = 10 * 60_000;
|
|
27
|
+
const MAX_ENTRIES = 8;
|
|
28
|
+
|
|
29
|
+
/** Where each agent keeps its state, relative to the home folder. */
|
|
30
|
+
export const AGENT_LOCATIONS: Readonly<Record<string, readonly string[]>> = {
|
|
31
|
+
claude: [".claude", "Library/Caches/claude-cli-nodejs"],
|
|
32
|
+
codex: [".codex"],
|
|
33
|
+
cursor: [".cursor"],
|
|
34
|
+
grok: [".grok"],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type DuRunner = (paths: string[]) => Promise<string>;
|
|
38
|
+
|
|
39
|
+
const defaultDu: DuRunner = async (paths) => {
|
|
40
|
+
const { stdout } = await run("du", ["-sk", ...paths], {
|
|
41
|
+
encoding: "utf8", maxBuffer: 1_000_000, timeout: 120_000,
|
|
42
|
+
});
|
|
43
|
+
return String(stdout);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Parse `du -sk` lines (`<kilobytes>\t<path>`) into bytes by path. */
|
|
47
|
+
export function parseDuOutput(stdout: string): Map<string, number> {
|
|
48
|
+
const out = new Map<string, number>();
|
|
49
|
+
for (const line of stdout.split("\n")) {
|
|
50
|
+
const match = line.match(/^\s*(\d+)\s+(.+?)\s*$/);
|
|
51
|
+
if (match) out.set(match[2]!, Number(match[1]) * 1024);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A path shown as the person would type it: `~/.claude/projects`. */
|
|
57
|
+
export function displayPath(path: string, home: string): string {
|
|
58
|
+
return path.startsWith(`${home}/`) ? `~/${path.slice(home.length + 1)}` : path === home ? "~" : path;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The heaviest entries, with everything else folded into one line. */
|
|
62
|
+
export function summarize(
|
|
63
|
+
sizes: ReadonlyMap<string, number>, home: string, measuredAt: number,
|
|
64
|
+
): AgentDiskUsage {
|
|
65
|
+
const sorted = [...sizes.entries()].sort((a, b) => b[1] - a[1]);
|
|
66
|
+
const totalBytes = sorted.reduce((sum, [, bytes]) => sum + bytes, 0);
|
|
67
|
+
const shown = sorted.slice(0, MAX_ENTRIES).map(([path, bytes]) => ({ path: displayPath(path, home), bytes }));
|
|
68
|
+
const rest = sorted.slice(MAX_ENTRIES).reduce((sum, [, bytes]) => sum + bytes, 0);
|
|
69
|
+
if (rest > 0) shown.push({ path: "…", bytes: rest });
|
|
70
|
+
return { measuredAt, totalBytes, entries: shown };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Measure one agent's folders now: each top-level child of each location. */
|
|
74
|
+
export async function measureAgentDisk(
|
|
75
|
+
agent: string,
|
|
76
|
+
options: { home?: string; du?: DuRunner; now?: () => number } = {},
|
|
77
|
+
): Promise<AgentDiskUsage | undefined> {
|
|
78
|
+
const home = options.home ?? homedir();
|
|
79
|
+
const du = options.du ?? defaultDu;
|
|
80
|
+
const targets: string[] = [];
|
|
81
|
+
for (const location of AGENT_LOCATIONS[agent] ?? []) {
|
|
82
|
+
const root = join(home, location);
|
|
83
|
+
if (!existsSync(root)) continue;
|
|
84
|
+
let children: string[] = [];
|
|
85
|
+
try {
|
|
86
|
+
children = readdirSync(root);
|
|
87
|
+
} catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (children.length === 0) targets.push(root);
|
|
91
|
+
for (const child of children) targets.push(join(root, child));
|
|
92
|
+
}
|
|
93
|
+
if (targets.length === 0) return undefined;
|
|
94
|
+
try {
|
|
95
|
+
const sizes = parseDuOutput(await du(targets));
|
|
96
|
+
return summarize(sizes, home, (options.now ?? Date.now)());
|
|
97
|
+
} catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type CacheEntry = { usage?: AgentDiskUsage; measuredAt: number; pending?: Promise<void> };
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The last measurement per agent, refreshed in the background once it is
|
|
106
|
+
* ten minutes old. Returns nothing for an agent that has never been measured;
|
|
107
|
+
* the first sample kicks the measurement off and a later one carries it.
|
|
108
|
+
*/
|
|
109
|
+
export function createDiskUsageSampler(
|
|
110
|
+
options: { home?: string; du?: DuRunner; now?: () => number; ttlMs?: number } = {},
|
|
111
|
+
) {
|
|
112
|
+
const now = options.now ?? Date.now;
|
|
113
|
+
const ttl = options.ttlMs ?? DISK_TTL_MS;
|
|
114
|
+
const cache = new Map<string, CacheEntry>();
|
|
115
|
+
const refresh = (agent: string): void => {
|
|
116
|
+
const entry = cache.get(agent) ?? { measuredAt: 0 };
|
|
117
|
+
if (entry.pending) return;
|
|
118
|
+
entry.pending = measureAgentDisk(agent, options)
|
|
119
|
+
.then((usage) => {
|
|
120
|
+
cache.set(agent, { usage: usage ?? entry.usage, measuredAt: now() });
|
|
121
|
+
})
|
|
122
|
+
.catch(() => {
|
|
123
|
+
cache.set(agent, { usage: entry.usage, measuredAt: now() });
|
|
124
|
+
});
|
|
125
|
+
cache.set(agent, entry);
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
sample(agents: readonly string[]): Record<string, AgentDiskUsage> {
|
|
129
|
+
const out: Record<string, AgentDiskUsage> = {};
|
|
130
|
+
for (const agent of agents) {
|
|
131
|
+
if (!(agent in AGENT_LOCATIONS)) continue;
|
|
132
|
+
const entry = cache.get(agent);
|
|
133
|
+
if (!entry || now() - entry.measuredAt >= ttl) refresh(agent);
|
|
134
|
+
const usage = cache.get(agent)?.usage;
|
|
135
|
+
if (usage) out[agent] = usage;
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
},
|
|
139
|
+
/** For tests: wait for any measurement in flight. */
|
|
140
|
+
async settle(): Promise<void> {
|
|
141
|
+
await Promise.all([...cache.values()].map((entry) => entry.pending ?? Promise.resolve()));
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
MachineLoad,
|
|
4
4
|
SessionsStatus,
|
|
5
5
|
} from "../../../../packages/protocol/schema";
|
|
6
|
+
import { createDiskUsageSampler, type AgentDiskUsage } from "./disk-usage";
|
|
6
7
|
import type { AgentProcessLoad } from "./process-sampler";
|
|
7
8
|
import { sampleAgentProcesses } from "./process-sampler";
|
|
8
9
|
import { providerScanCost, type ProviderScanSample } from "./scan-cost";
|
|
@@ -44,6 +45,7 @@ export function buildMachineLoad(input: {
|
|
|
44
45
|
processes: Record<string, AgentProcessLoad>;
|
|
45
46
|
self: MonitorSelfLoad;
|
|
46
47
|
scanCost?: Record<string, ProviderScanSample>;
|
|
48
|
+
disk?: Record<string, AgentDiskUsage>;
|
|
47
49
|
machine?: string;
|
|
48
50
|
now?: number;
|
|
49
51
|
}): MachineLoad {
|
|
@@ -84,6 +86,10 @@ export function buildMachineLoad(input: {
|
|
|
84
86
|
processes: input.processes[agent]?.processes ?? 0,
|
|
85
87
|
cpuPercent: input.processes[agent]?.cpuPercent ?? 0,
|
|
86
88
|
memoryBytes: input.processes[agent]?.memoryBytes ?? 0,
|
|
89
|
+
topProcesses: input.processes[agent]?.groups ?? [],
|
|
90
|
+
processList: input.processes[agent]?.rows ?? [],
|
|
91
|
+
chats: input.processes[agent]?.chats ?? [],
|
|
92
|
+
...(input.disk?.[agent] ? { disk: input.disk[agent] } : {}),
|
|
87
93
|
sessions: sessionsByAgent.get(agent) ?? 0,
|
|
88
94
|
scanMs: scanCost[agent]?.durationMs ?? 0,
|
|
89
95
|
tokensRecent: tokensByAgent.get(agent) ?? 0,
|
|
@@ -103,13 +109,32 @@ type LoadRelay = {
|
|
|
103
109
|
): Promise<void>;
|
|
104
110
|
};
|
|
105
111
|
|
|
112
|
+
/** One line a person can grep in the helper log: what each agent weighed. */
|
|
113
|
+
export function describeLoad(load: MachineLoad): string {
|
|
114
|
+
const agents = load.agents.map((agent) => {
|
|
115
|
+
const memory = `${Math.round(agent.memoryBytes / 1_000_000)} MB`;
|
|
116
|
+
const chats = agent.chats?.length ? ` in ${agent.chats.length} chats` : "";
|
|
117
|
+
return `${agent.agent} ${agent.processes} procs ${agent.cpuPercent}% ${memory}${chats}`;
|
|
118
|
+
});
|
|
119
|
+
return `[load] ${agents.length > 0 ? agents.join(" · ") : "no agent processes"}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const LOAD_LOG_INTERVAL_MS = 5 * 60_000;
|
|
123
|
+
|
|
106
124
|
/** Create one publisher so its monitor CPU baseline survives between reports. */
|
|
107
125
|
export function createMachineLoadPublisher(dependencies: {
|
|
108
126
|
sampleProcesses?: typeof sampleAgentProcesses;
|
|
109
127
|
sampleSelf?: () => MonitorSelfLoad;
|
|
128
|
+
sampleDisk?: (agents: readonly string[]) => Record<string, AgentDiskUsage>;
|
|
129
|
+
/** Told about a sample now and then, so the helper log says what was measured. */
|
|
130
|
+
log?: (line: string) => void;
|
|
131
|
+
now?: () => number;
|
|
110
132
|
} = {}) {
|
|
111
133
|
const sampleProcesses = dependencies.sampleProcesses ?? sampleAgentProcesses;
|
|
112
134
|
const sampleSelf = dependencies.sampleSelf ?? monitorLoadSampler();
|
|
135
|
+
const sampleDisk = dependencies.sampleDisk ?? createDiskUsageSampler().sample;
|
|
136
|
+
const now = dependencies.now ?? Date.now;
|
|
137
|
+
let loggedAt: number | undefined;
|
|
113
138
|
let active: Promise<MachineLoad> | undefined;
|
|
114
139
|
return (
|
|
115
140
|
client: LoadRelay,
|
|
@@ -119,7 +144,12 @@ export function createMachineLoadPublisher(dependencies: {
|
|
|
119
144
|
if (active) return active;
|
|
120
145
|
active = (async () => {
|
|
121
146
|
const processes = await sampleProcesses();
|
|
122
|
-
const
|
|
147
|
+
const disk = sampleDisk(Object.keys(processes));
|
|
148
|
+
const load = buildMachineLoad({ status, processes, self: sampleSelf(), disk });
|
|
149
|
+
if (dependencies.log && (loggedAt === undefined || now() - loggedAt >= LOAD_LOG_INTERVAL_MS)) {
|
|
150
|
+
loggedAt = now();
|
|
151
|
+
dependencies.log(describeLoad(load));
|
|
152
|
+
}
|
|
123
153
|
await client.send(load, "phone", { ttlMs: intervalMs * 3, reliable: false });
|
|
124
154
|
return load;
|
|
125
155
|
})().finally(() => {
|