granttap-mcp 0.8.3 → 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 +56 -1
- 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 +30 -1
- package/apps/bridge/src/machine-load/process-sampler.ts +175 -13
- 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/checkpoint.ts +32 -6
- package/apps/bridge/src/mesh/journal.ts +10 -3
- package/apps/bridge/src/mesh/prompt-context.ts +50 -25
- 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 +15 -0
- 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 +28 -2
- package/apps/bridge/src/sessions.ts +25 -0
- package/apps/mcp/src/mcp-tools/interaction.ts +101 -24
- 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 +39 -0
- package/packages/protocol/messages/mesh.ts +6 -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
|
|
@@ -217,6 +235,25 @@ executable path and the command line are read separately and joined by pid,
|
|
|
217
235
|
because a path with a space in it (`~/Library/Application Support/Claude/…`)
|
|
218
236
|
cannot be recovered from a command line split on whitespace.
|
|
219
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
|
+
|
|
220
257
|
### One computer, whatever the network calls it
|
|
221
258
|
|
|
222
259
|
The Mesh keys a computer by an identity written down once, on first use, in
|
|
@@ -309,6 +346,24 @@ capability kind, so the phone shows what is actually in force rather than what
|
|
|
309
346
|
was sent. Revision zero is a Project with no policy yet, and it is reported so
|
|
310
347
|
the first policy can be written.
|
|
311
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
|
+
|
|
312
367
|
Evaluation happens in the provider hook before the action runs, with a deadline
|
|
313
368
|
long enough for a busy machine to answer. A missed answer falls back to the
|
|
314
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 {
|
|
@@ -85,6 +87,9 @@ export function buildMachineLoad(input: {
|
|
|
85
87
|
cpuPercent: input.processes[agent]?.cpuPercent ?? 0,
|
|
86
88
|
memoryBytes: input.processes[agent]?.memoryBytes ?? 0,
|
|
87
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] } : {}),
|
|
88
93
|
sessions: sessionsByAgent.get(agent) ?? 0,
|
|
89
94
|
scanMs: scanCost[agent]?.durationMs ?? 0,
|
|
90
95
|
tokensRecent: tokensByAgent.get(agent) ?? 0,
|
|
@@ -104,13 +109,32 @@ type LoadRelay = {
|
|
|
104
109
|
): Promise<void>;
|
|
105
110
|
};
|
|
106
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
|
+
|
|
107
124
|
/** Create one publisher so its monitor CPU baseline survives between reports. */
|
|
108
125
|
export function createMachineLoadPublisher(dependencies: {
|
|
109
126
|
sampleProcesses?: typeof sampleAgentProcesses;
|
|
110
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;
|
|
111
132
|
} = {}) {
|
|
112
133
|
const sampleProcesses = dependencies.sampleProcesses ?? sampleAgentProcesses;
|
|
113
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;
|
|
114
138
|
let active: Promise<MachineLoad> | undefined;
|
|
115
139
|
return (
|
|
116
140
|
client: LoadRelay,
|
|
@@ -120,7 +144,12 @@ export function createMachineLoadPublisher(dependencies: {
|
|
|
120
144
|
if (active) return active;
|
|
121
145
|
active = (async () => {
|
|
122
146
|
const processes = await sampleProcesses();
|
|
123
|
-
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
|
+
}
|
|
124
153
|
await client.send(load, "phone", { ttlMs: intervalMs * 3, reliable: false });
|
|
125
154
|
return load;
|
|
126
155
|
})().finally(() => {
|