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.
Files changed (52) hide show
  1. package/README.md +79 -6
  2. package/apps/bridge/src/attachment-store.ts +89 -0
  3. package/apps/bridge/src/config/capability-policy.ts +36 -1
  4. package/apps/bridge/src/config/runtime.ts +15 -7
  5. package/apps/bridge/src/machine-load/disk-usage.ts +144 -0
  6. package/apps/bridge/src/machine-load/index.ts +31 -1
  7. package/apps/bridge/src/machine-load/process-sampler.ts +317 -23
  8. package/apps/bridge/src/mesh/README.md +5 -0
  9. package/apps/bridge/src/mesh/call-scope.ts +13 -1
  10. package/apps/bridge/src/mesh/capability.ts +8 -2
  11. package/apps/bridge/src/mesh/catalog.ts +1 -0
  12. package/apps/bridge/src/mesh/checkpoint.ts +32 -6
  13. package/apps/bridge/src/mesh/journal.ts +10 -3
  14. package/apps/bridge/src/mesh/map.ts +53 -12
  15. package/apps/bridge/src/mesh/prompt-context.ts +51 -27
  16. package/apps/bridge/src/mesh/remote.ts +55 -0
  17. package/apps/bridge/src/mesh/runtime-dependencies.ts +4 -0
  18. package/apps/bridge/src/mesh/runtime-handoff.ts +9 -1
  19. package/apps/bridge/src/mesh/runtime.ts +32 -4
  20. package/apps/bridge/src/mesh/store-state.ts +82 -25
  21. package/apps/bridge/src/mesh/store-sync.ts +189 -0
  22. package/apps/bridge/src/mesh/store.ts +93 -8
  23. package/apps/bridge/src/monitor.ts +141 -11
  24. package/apps/bridge/src/policy/capability-fingerprint.ts +33 -7
  25. package/apps/bridge/src/policy/effective-action.ts +26 -3
  26. package/apps/bridge/src/policy/governed-projects.ts +66 -0
  27. package/apps/bridge/src/project-policy/mapping.ts +22 -5
  28. package/apps/bridge/src/project-policy/runtime.ts +66 -6
  29. package/apps/bridge/src/reply/process.ts +54 -13
  30. package/apps/bridge/src/reply/provider-headless.ts +2 -0
  31. package/apps/bridge/src/reply/types.ts +2 -0
  32. package/apps/bridge/src/reply.ts +14 -3
  33. package/apps/bridge/src/sessions/activity-helpers.ts +14 -0
  34. package/apps/bridge/src/sessions/claude.ts +58 -1
  35. package/apps/bridge/src/sessions/codex.ts +9 -0
  36. package/apps/bridge/src/sessions/edit-stats.ts +220 -0
  37. package/apps/bridge/src/sessions/telemetry/command-preview.ts +13 -3
  38. package/apps/bridge/src/sessions/telemetry.ts +59 -5
  39. package/apps/bridge/src/sessions.ts +25 -0
  40. package/apps/mcp/src/mcp-tools/interaction.ts +101 -24
  41. package/apps/mcp/src/mcp-tools/mesh-resource.ts +59 -12
  42. package/apps/mcp/src/mcp-tools/relay.ts +43 -3
  43. package/apps/mcp/src/mesh-stdio.ts +4 -1
  44. package/apps/mcp/src/server.ts +27 -2
  45. package/package.json +1 -1
  46. package/packages/protocol/messages/capabilities.ts +7 -0
  47. package/packages/protocol/messages/interaction.ts +28 -0
  48. package/packages/protocol/messages/machine.ts +51 -0
  49. package/packages/protocol/messages/mesh.ts +10 -0
  50. package/packages/protocol/messages/project-policy.ts +36 -1
  51. package/packages/protocol/messages/sessions.ts +34 -0
  52. 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; the destination says so if the commit has not reached it.
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
- `granttap://mesh/{capability}/map`, one page of markdown with the whole
234
- Project: Tasks, who edits which module, the other side of each repository,
235
- dependencies, and what just happened. Background runs themselves receive
236
- nothing from the hook; the journal is kept for the session a person is in.
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: Array.isArray(raw.sessionShellDisabled)
138
- ? [...new Set<string>(
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 load = buildMachineLoad({ status, processes, self: sampleSelf() });
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(() => {