@stage-labs/metro 0.1.0-beta.222 → 0.1.0-beta.223
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/server/machine.ts +7 -2
- package/runtime/node_modules/@metro-labs/daemon/src/server/resources.ts +160 -0
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.223",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -50,6 +50,7 @@ import { materializeFrom, reloadFrom } from '../stations/materialize.js';
|
|
|
50
50
|
import type { StationName } from '@metro-labs/core/station-names';
|
|
51
51
|
import { startUploadReaper } from '../files/upload-store.js';
|
|
52
52
|
import { startAttachReaper } from '../files/attach-reaper.js';
|
|
53
|
+
import { startResourceSampler } from '../server/resources.js';
|
|
53
54
|
|
|
54
55
|
installCrashGuard();
|
|
55
56
|
acquireLock(join(STATE_DIR, '.tail-lock'));
|
|
@@ -142,6 +143,7 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
142
143
|
syncPluginServers(readLocalConnectors());
|
|
143
144
|
startUploadReaper();
|
|
144
145
|
startAttachReaper();
|
|
146
|
+
startResourceSampler();
|
|
145
147
|
announceLocalEndpoint();
|
|
146
148
|
tunnel?.start();
|
|
147
149
|
log.info(
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
readLocalAgentFile,
|
|
45
45
|
} from '../agents/file-admin.js';
|
|
46
46
|
import { readModelConfig } from '../gateway/model-config.js';
|
|
47
|
+
import { resourcesFor } from '../server/resources.js';
|
|
47
48
|
import type { StationName } from '@metro-labs/core/station-names';
|
|
48
49
|
|
|
49
50
|
export interface LocalModeDeps {
|
|
@@ -197,7 +198,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
197
198
|
updateApi: { restart: deps.restart },
|
|
198
199
|
controlApi: { restart: deps.restart, stop: deps.stop },
|
|
199
200
|
ownerApi: { setOwner: (owner) => setLocalOwner(owner) },
|
|
200
|
-
machineApi: {},
|
|
201
|
+
machineApi: { resources: resourcesFor },
|
|
201
202
|
modelApi: {},
|
|
202
203
|
gateway: { config: readModelConfig },
|
|
203
204
|
terminalApi: {},
|
|
@@ -13,6 +13,7 @@ const PATH = '/api/server';
|
|
|
13
13
|
|
|
14
14
|
export interface MachineApiDeps {
|
|
15
15
|
startedAt?: string;
|
|
16
|
+
resources?: (range: string) => unknown;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
const bootedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
|
|
@@ -23,7 +24,7 @@ interface Disk {
|
|
|
23
24
|
freeBytes: number;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
async function diskInfo(path = homedir()): Promise<Disk | null> {
|
|
27
|
+
export async function diskInfo(path = homedir()): Promise<Disk | null> {
|
|
27
28
|
try {
|
|
28
29
|
const s = await statfs(path);
|
|
29
30
|
return { path, totalBytes: s.bsize * s.blocks, freeBytes: s.bsize * s.bavail };
|
|
@@ -54,5 +55,9 @@ export async function machineInfo(startedAt = bootedAt): Promise<Record<string,
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export function handleMachineRequest(req: IncomingMessage, res: ServerResponse, deps: MachineApiDeps): boolean {
|
|
57
|
-
return sessionRoute(req, res, { methods: { [PATH]: ['GET'] }, admin: false, label: 'machine-api' }, () =>
|
|
58
|
+
return sessionRoute(req, res, { methods: { [PATH]: ['GET'] }, admin: false, label: 'machine-api' }, async () => {
|
|
59
|
+
const info = await machineInfo(deps.startedAt);
|
|
60
|
+
const range = new URL(req.url ?? '', 'http://localhost').searchParams.get('range');
|
|
61
|
+
return range === null || deps.resources === undefined ? info : { ...info, resources: deps.resources(range) };
|
|
62
|
+
});
|
|
58
63
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { cpus, freemem, homedir, totalmem } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
5
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
6
|
+
import { diskInfo } from './machine.js';
|
|
7
|
+
|
|
8
|
+
const SAMPLE_MS = 60_000;
|
|
9
|
+
const HOUR_MS = 3_600_000;
|
|
10
|
+
const KEEP_MS = 7 * 24 * HOUR_MS;
|
|
11
|
+
const POINTS = 240;
|
|
12
|
+
const COMPACT_EVERY = 1_440;
|
|
13
|
+
const RANGES: Readonly<Record<string, number>> = { '1h': HOUR_MS, '24h': 24 * HOUR_MS, '7d': KEEP_MS };
|
|
14
|
+
const FIELDS = ['at', 'cpu', 'memUsed', 'memTotal', 'diskUsed', 'diskTotal'] as const;
|
|
15
|
+
|
|
16
|
+
export type ResourceSample = Record<(typeof FIELDS)[number], number>;
|
|
17
|
+
|
|
18
|
+
interface CpuTicks {
|
|
19
|
+
busy: number;
|
|
20
|
+
total: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const state: { samples: ResourceSample[]; appended: number } = { samples: [], appended: 0 };
|
|
24
|
+
|
|
25
|
+
const resourcesFile = (): string => join(homedir(), '.metro', 'resources.jsonl');
|
|
26
|
+
|
|
27
|
+
function cpuTicks(): CpuTicks {
|
|
28
|
+
let busy = 0;
|
|
29
|
+
let total = 0;
|
|
30
|
+
for (const { times } of cpus()) {
|
|
31
|
+
const sum = times.user + times.nice + times.sys + times.idle + times.irq;
|
|
32
|
+
total += sum;
|
|
33
|
+
busy += sum - times.idle;
|
|
34
|
+
}
|
|
35
|
+
return { busy, total };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function cpuPercent(before: CpuTicks, after: CpuTicks): number {
|
|
39
|
+
const total = after.total - before.total;
|
|
40
|
+
if (total <= 0) return 0;
|
|
41
|
+
return Math.min(100, Math.max(0, ((after.busy - before.busy) / total) * 100));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toSample(value: unknown): ResourceSample | null {
|
|
45
|
+
if (typeof value !== 'object' || value === null) return null;
|
|
46
|
+
const out: Partial<ResourceSample> = {};
|
|
47
|
+
for (const field of FIELDS) {
|
|
48
|
+
const v: unknown = (value as Record<string, unknown>)[field];
|
|
49
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) return null;
|
|
50
|
+
out[field] = v;
|
|
51
|
+
}
|
|
52
|
+
return out as ResourceSample;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseLine(line: string): ResourceSample | null {
|
|
56
|
+
if (line.trim() === '') return null;
|
|
57
|
+
try {
|
|
58
|
+
return toSample(JSON.parse(line));
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const toLine = (sample: ResourceSample): string => `${JSON.stringify(sample)}\n`;
|
|
65
|
+
|
|
66
|
+
export function readSamples(file = resourcesFile(), now = Date.now()): ResourceSample[] {
|
|
67
|
+
let text: string;
|
|
68
|
+
try {
|
|
69
|
+
text = readFileSync(file, 'utf8');
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
return text.split('\n').flatMap((line) => parseLine(line) ?? []).filter((s) => s.at > now - KEEP_MS);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function rewrite(file: string): void {
|
|
77
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
78
|
+
writeFileSync(file, state.samples.map(toLine).join(''));
|
|
79
|
+
state.appended = 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function recordSample(sample: ResourceSample, file = resourcesFile()): void {
|
|
83
|
+
state.samples.push(sample);
|
|
84
|
+
const first = state.samples.findIndex((s) => s.at > sample.at - KEEP_MS);
|
|
85
|
+
if (first > 0) state.samples.splice(0, first);
|
|
86
|
+
if (state.appended >= COMPACT_EVERY) {
|
|
87
|
+
rewrite(file);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
91
|
+
appendFileSync(file, toLine(sample));
|
|
92
|
+
state.appended += 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function loadSamples(file = resourcesFile()): void {
|
|
96
|
+
state.samples = readSamples(file);
|
|
97
|
+
state.appended = 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function takeSample(before: CpuTicks): Promise<{ sample: ResourceSample; ticks: CpuTicks }> {
|
|
101
|
+
const ticks = cpuTicks();
|
|
102
|
+
const disk = await diskInfo();
|
|
103
|
+
const memTotal = totalmem();
|
|
104
|
+
return {
|
|
105
|
+
ticks,
|
|
106
|
+
sample: {
|
|
107
|
+
at: Date.now(),
|
|
108
|
+
cpu: cpuPercent(before, ticks),
|
|
109
|
+
memUsed: memTotal - freemem(),
|
|
110
|
+
memTotal,
|
|
111
|
+
diskUsed: disk === null ? 0 : disk.totalBytes - disk.freeBytes,
|
|
112
|
+
diskTotal: disk?.totalBytes ?? 0,
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function startResourceSampler(): void {
|
|
118
|
+
try {
|
|
119
|
+
loadSamples();
|
|
120
|
+
rewrite(resourcesFile());
|
|
121
|
+
} catch (err) {
|
|
122
|
+
log.warn({ err: errMsg(err) }, 'resources: could not load the history');
|
|
123
|
+
}
|
|
124
|
+
let ticks = cpuTicks();
|
|
125
|
+
const run = (): void => {
|
|
126
|
+
takeSample(ticks)
|
|
127
|
+
.then((next) => {
|
|
128
|
+
ticks = next.ticks;
|
|
129
|
+
recordSample(next.sample);
|
|
130
|
+
})
|
|
131
|
+
.catch((err: unknown) => {
|
|
132
|
+
log.warn({ err: errMsg(err) }, 'resources: could not record a sample');
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
setInterval(run, SAMPLE_MS).unref?.();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function average(at: number, group: ResourceSample[]): ResourceSample {
|
|
139
|
+
const mean = (field: (typeof FIELDS)[number]): number => group.reduce((sum, s) => sum + s[field], 0) / group.length;
|
|
140
|
+
return { at, cpu: mean('cpu'), memUsed: mean('memUsed'), memTotal: mean('memTotal'), diskUsed: mean('diskUsed'), diskTotal: mean('diskTotal') };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function resourceSeries(samples: ResourceSample[], rangeMs: number, now = Date.now()): { stepMs: number; samples: ResourceSample[] } {
|
|
144
|
+
const stepMs = Math.max(1, Math.ceil(rangeMs / POINTS / SAMPLE_MS)) * SAMPLE_MS;
|
|
145
|
+
const buckets = new Map<number, ResourceSample[]>();
|
|
146
|
+
for (const sample of samples) {
|
|
147
|
+
if (sample.at <= now - rangeMs || sample.at > now) continue;
|
|
148
|
+
const key = Math.floor(sample.at / stepMs) * stepMs;
|
|
149
|
+
const group = buckets.get(key);
|
|
150
|
+
if (group === undefined) buckets.set(key, [sample]);
|
|
151
|
+
else group.push(sample);
|
|
152
|
+
}
|
|
153
|
+
return { stepMs, samples: [...buckets].map(([at, group]) => average(at, group)) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function resourcesFor(range: string): { range: string; sampleMs: number; stepMs: number; samples: ResourceSample[] } {
|
|
157
|
+
const rangeMs = Object.hasOwn(RANGES, range) ? RANGES[range] : undefined;
|
|
158
|
+
if (rangeMs === undefined) throw new ApiError('range must be 1h, 24h or 7d', 400);
|
|
159
|
+
return { range, sampleMs: SAMPLE_MS, ...resourceSeries(state.samples, rangeMs) };
|
|
160
|
+
}
|
package/runtime/runtime.json
CHANGED