@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.222",
3
+ "version": "0.1.0-beta.223",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.222",
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' }, () => machineInfo(deps.startedAt));
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
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.222"
2
+ "version": "0.1.0-beta.223"
3
3
  }