shraga 0.1.80 → 0.1.82

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.
@@ -13,7 +13,7 @@
13
13
  <link rel="preconnect" href="https://fonts.googleapis.com" />
14
14
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
15
15
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
16
- <script type="module" crossorigin src="/assets/index-h0IKxLlQ.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-yDX1hxfO.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-J2NH6FvE.css">
18
18
  </head>
19
19
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.80",
3
+ "version": "0.1.82",
4
4
  "description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import type { AgentSocket, ServerEvent } from '@/lib/ws';
3
3
  import { cn } from '@/lib/utils';
4
4
  import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
5
+ import { DISK_WARN_PCT, DISK_CRIT_PCT, formatBytes } from '../../shared/disk';
5
6
 
6
7
  type Sample = Extract<ServerEvent, { type: 'stats' }>['sample'];
7
8
 
@@ -69,6 +70,12 @@ export function MachineStats({ socket, getToken }: Props) {
69
70
  <div className="w-full flex flex-wrap items-center justify-center gap-x-2 gap-y-0.5 text-[10px] text-muted-foreground/60">
70
71
  <Metric label="cpu" value={latest.cpu} series={samples.map(s => s.cpu)} />
71
72
  <Metric label="mem" value={latest.mem} series={samples.map(s => s.mem)} />
73
+ {latest.disk >= 0 && (
74
+ // Gauge, not sparkline: the server refreshes disk once a minute, so a 10-min trend line would
75
+ // be a near-flat 10-point stair that reads as broken. A current-value bar is the honest shape.
76
+ <Metric label="disk" value={latest.disk} severity={diskSeverity(latest.disk)}
77
+ title={diskTip(latest)} />
78
+ )}
72
79
  <ClaudeUsageMetric getToken={getToken} />
73
80
  </div>
74
81
  );
@@ -302,8 +309,20 @@ export function untilLabel(iso: string | null): string | null {
302
309
  /** Only severities we have actually SEEN mean something here. The vendor's vocabulary is not
303
310
  * documented and not fully observed, so an unrecognised value falls through to the percentage
304
311
  * thresholds — treating "anything that isn't normal" as elevated would paint the widget a
305
- * permanent amber the first time the endpoint adds a benign new word. */
306
- const ELEVATED: Record<string, string> = { critical: 'text-red-500', exceeded: 'text-red-500', warning: 'text-amber-500', warn: 'text-amber-500' };
312
+ * permanent amber the first time the endpoint adds a benign new word. 'ok' is ours, not the
313
+ * vendor's: it lets a metric with its OWN bands (disk) say "fine" instead of inheriting 75/90. */
314
+ // Thresholds come from the one shared module the server reads too (src/shared/disk.ts) — re-exported
315
+ // here so existing importers of this component keep their import site. Not re-declared: a second
316
+ // literal is a drift waiting to happen.
317
+ export { DISK_WARN_PCT, DISK_CRIT_PCT };
318
+
319
+ /** Disk has its own bands: 80% full is unremarkable for a disk, so it must NOT inherit the generic
320
+ * 75/90 percent colouring — hence an explicit 'ok' rather than falling through to those. */
321
+ export function diskSeverity(pct: number): string {
322
+ return pct >= DISK_CRIT_PCT ? 'critical' : pct >= DISK_WARN_PCT ? 'warning' : 'ok';
323
+ }
324
+
325
+ const ELEVATED: Record<string, string> = { critical: 'text-red-500', exceeded: 'text-red-500', warning: 'text-amber-500', warn: 'text-amber-500', ok: 'text-emerald-500' };
307
326
 
308
327
  function level(v: number, severity?: string) {
309
328
  const known = severity ? ELEVATED[severity] : undefined;
@@ -311,6 +330,19 @@ function level(v: number, severity?: string) {
311
330
  return v >= 90 ? 'text-red-500' : v >= 75 ? 'text-amber-500' : 'text-emerald-500';
312
331
  }
313
332
 
333
+ /**
334
+ * Hover text. The percentage alone cannot tell you whether 93% is 3GB or 300GB left, which is the
335
+ * first thing anyone asks when they see it amber — so the tooltip carries the actual figures. Falls
336
+ * back to the bare percentage on a sample from a server too old to send the byte fields.
337
+ */
338
+ function diskTip(s: { disk: number; diskUsedBytes?: number; diskTotalBytes?: number }): string {
339
+ const bounds = `warn at ${DISK_WARN_PCT}%, critical at ${DISK_CRIT_PCT}%`;
340
+ if (s.diskUsedBytes == null || s.diskTotalBytes == null) return `disk ${s.disk}% used — ${bounds}`;
341
+ const free = Math.max(0, s.diskTotalBytes - s.diskUsedBytes);
342
+ return `disk ${s.disk}% used — ${formatBytes(s.diskUsedBytes)} of ${formatBytes(s.diskTotalBytes)}, `
343
+ + `${formatBytes(free)} free — ${bounds}`;
344
+ }
345
+
314
346
  function Metric({ label, value, series, title, severity }: { label: string; value: number; series?: number[]; title?: string; severity?: string }) {
315
347
  const tone = level(value, severity);
316
348
  // Plain-text fallback only where nothing richer exists (cpu/mem). The usage metric passes no title:
@@ -48,7 +48,7 @@ export type ServerEvent =
48
48
  | { type: 'unread_cleared'; sessionId: string }
49
49
  | { type: 'pty_list_changed'; sessionId: string }
50
50
  | { type: 'workspace_layout_changed' }
51
- | { type: 'stats'; sample: { t: number; cpu: number; mem: number; load: number } };
51
+ | { type: 'stats'; sample: { t: number; cpu: number; mem: number; load: number; disk: number; diskUsedBytes?: number; diskTotalBytes?: number } };
52
52
 
53
53
  type Listener = (event: ServerEvent) => void;
54
54
 
@@ -231,7 +231,15 @@ export async function startJob(owner: JobOwner, command: string): Promise<string
231
231
  // stdin is /dev/null so a child can never block on (or steal) input; stdout+stderr go straight
232
232
  // to the fd — nothing is buffered in this process, so a chatty job costs disk, not memory.
233
233
  const proc = spawn('/bin/sh', ['-c', buildShell(id, cmd)], {
234
- cwd: owner.cwd, env: { ...childEnv(), ...owner.env },
234
+ cwd: owner.cwd,
235
+ env: {
236
+ ...childEnv(),
237
+ ...owner.env,
238
+ // A long-running launcher can gate on these: only a REGISTERED job gets a completion wake,
239
+ // so one started in the foreground orphans silently when its 60s tool call is killed.
240
+ SHRAGA_JOB_ID: id,
241
+ SHRAGA_BG_JOB: '1',
242
+ },
235
243
  detached: true, stdio: ['ignore', fd, fd],
236
244
  });
237
245
  j.pid = proc.pid;
@@ -251,6 +259,47 @@ export async function startJob(owner: JobOwner, command: string): Promise<string
251
259
  return id;
252
260
  }
253
261
 
262
+ /** The little of a spawned child this module needs to adopt one (node's ChildProcess satisfies it). */
263
+ type AdoptableProcess = {
264
+ pid?: number | null;
265
+ stdout?: { on(ev: 'data', cb: (chunk: unknown) => void): void } | null;
266
+ stderr?: { on(ev: 'data', cb: (chunk: unknown) => void): void } | null;
267
+ on(ev: 'close' | 'error', cb: (arg: never) => void): void;
268
+ unref?: () => void;
269
+ };
270
+
271
+ /**
272
+ * Register an ALREADY-RUNNING child as a background job. The Shell tool hands a foreground command
273
+ * over at its deadline instead of killing it: the work continues, and from here it is an ordinary job
274
+ * — polled with ShellOutput, and reported back to the session when it ends. Without this the deadline
275
+ * stays a kill, which is what abandoned a scheduled run midway.
276
+ */
277
+ export function adoptJob(owner: JobOwner, command: string, proc: AdoptableProcess, seed = ''): string {
278
+ const id = `job-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
279
+ const j: JobRecord = {
280
+ id, sessionId: owner.sessionId, uid: owner.uid, userEmail: owner.userEmail,
281
+ command: command.trim(), cwd: owner.cwd, startedAt: Date.now(), status: 'running',
282
+ pid: proc.pid ?? undefined,
283
+ };
284
+ // The child was spawned with pipes by the caller, so output is appended here rather than dup'd to
285
+ // an fd the way a job we spawn ourselves does it.
286
+ const append = (chunk: unknown) => {
287
+ const text = typeof chunk === 'string' ? chunk : String(chunk ?? '');
288
+ if (!text) return;
289
+ try { writeFileSync(logFile(id), text, { flag: 'a' }); } catch { /* the job outlives its log */ }
290
+ };
291
+ if (seed) append(seed);
292
+ proc.stdout?.on('data', append);
293
+ proc.stderr?.on('data', append);
294
+ proc.on('error', ((err: Error) => finish(id, 'error', undefined, `adopted job error: ${err?.message ?? err}`)) as never);
295
+ proc.on('close', ((code: number | null) => finish(id, 'exited', code ?? undefined)) as never);
296
+ proc.unref?.();
297
+ records.set(id, j);
298
+ save(j);
299
+ console.log(`${PREFIX} adopted ${id} pid=${j.pid} session=${j.sessionId.slice(0, 8)} cmd=${j.command.slice(0, 120)}`);
300
+ return id;
301
+ }
302
+
254
303
  /** Record a terminal state exactly once, then schedule the follow-up. */
255
304
  function finish(id: string, status: JobStatus, exitCode?: number, note?: string): void {
256
305
  const j = records.get(id);
@@ -479,6 +528,7 @@ export function sessionJobRegistry(owner: JobOwner) {
479
528
  };
480
529
  return {
481
530
  start: (command: string) => startJob(owner, command),
531
+ adopt: (command: string, proc: AdoptableProcess, seed?: string) => adoptJob(owner, command, proc, seed),
482
532
  output(id: string): string | null {
483
533
  const j = mine(id);
484
534
  if (!j) return null;
@@ -83,7 +83,7 @@ export type WsEvent =
83
83
  | { type: 'done'; sessionId: string; stopReason?: 'end_turn' | 'max_turns_reached' | (string & {}); builtinHandled?: boolean }
84
84
  | { type: 'model_resolved'; sessionId: string; model: string }
85
85
  | { type: 'error'; message: string }
86
- | { type: 'stats'; sample: { t: number; cpu: number; mem: number; load: number } };
86
+ | { type: 'stats'; sample: { t: number; cpu: number; mem: number; load: number; disk: number; diskUsedBytes?: number; diskTotalBytes?: number } };
87
87
  // Add-on engines/features emit their OWN events (e.g. a duplex voice brain's `duplex_*`) through the
88
88
  // object-typed `emitToSession()` bus (session-bus.ts) — NOT this union. So the core names none of them
89
89
  // here, yet forwards them verbatim to clients. Keep this union the closed set of core-owned events.
@@ -3,6 +3,7 @@
3
3
  // they seed from getStats() and then receive live points over WS. N users = 1 sampler.
4
4
  import os from 'node:os';
5
5
  import { readFileSync } from 'node:fs';
6
+ import { statfs } from 'node:fs/promises';
6
7
  import { execFile } from 'node:child_process';
7
8
  import { promisify } from 'node:util';
8
9
 
@@ -13,13 +14,27 @@ export interface StatSample {
13
14
  cpu: number; // 0-100, host CPU utilization since last sample
14
15
  mem: number; // 0-100, used / total memory
15
16
  load: number; // 1-min load average
17
+ disk: number; // 0-100, used of the root filesystem (cached, refreshed slowly — see refreshDisk)
18
+ /** Bytes used / total, for the hover tooltip. Omitted until the first successful refresh. */
19
+ diskUsedBytes?: number;
20
+ diskTotalBytes?: number;
16
21
  }
17
22
 
23
+ /** Hourly ceiling on the disk-failure warning, so a permanently broken mount cannot flood the log. */
24
+ const DISK_FAIL_LOG_INTERVAL_MS = 60 * 60 * 1000;
25
+
18
26
  export class StatsSamplerOptions {
19
27
  intervalMs = 5000;
20
28
  window = 120; // ring-buffer length (120 × 5s = 10 min trend)
29
+ // Disk moves in minutes, not seconds, and statfs() stats the whole filesystem — no reason to pay
30
+ // for it on every 5s tick. Refreshed on its own slow cadence into a cache the sample just reads.
31
+ diskIntervalMs = 60_000;
21
32
  }
22
33
 
34
+ // Single source in ../shared/disk.ts, imported by the client widget too — re-exported here so
35
+ // server-side callers (and the tests) keep the same import site.
36
+ export { DISK_WARN_PCT, DISK_CRIT_PCT } from '../shared/disk';
37
+
23
38
  export class StatsSampler {
24
39
  public options: StatsSamplerOptions;
25
40
  private buffer: StatSample[] = [];
@@ -30,6 +45,17 @@ export class StatsSampler {
30
45
  // ~1-2s (longer under load) every 5s — stalling /api/version long enough to trip the health watchdog
31
46
  // into false-restarting a live server. Seed from the cheap os.freemem fallback until the first refresh.
32
47
  private memPct = 100 * (1 - os.freemem() / os.totalmem());
48
+ // Cached disk %, refreshed on the slow diskIntervalMs cadence. -1 until the first refresh lands, so
49
+ // an unread disk renders as "unknown" rather than a confident 0% (an empty disk) — and a failed
50
+ // refresh keeps the LAST known value rather than resetting it.
51
+ private diskPct = -1;
52
+ private diskUsedBytes: number | undefined;
53
+ private diskTotalBytes: number | undefined;
54
+ /** One refresh at a time — see refreshDisk. */
55
+ private diskInFlight = false;
56
+ /** Epoch ms of the last logged disk failure; 0 = none since the last success. */
57
+ private diskFailLoggedAt = 0;
58
+ private diskTimer: ReturnType<typeof setInterval> | null = null;
33
59
 
34
60
  public constructor(options?: Partial<StatsSamplerOptions>) {
35
61
  this.options = { ...new StatsSamplerOptions(), ...options };
@@ -38,6 +64,9 @@ export class StatsSampler {
38
64
  start(broadcast: (data: object) => void) {
39
65
  if (this.timer) return; // singleton — already running
40
66
  void this.refreshMem(); // prime the cache; each tick refreshes it async for the NEXT sample
67
+ void this.refreshDisk();
68
+ this.diskTimer = setInterval(() => { void this.refreshDisk(); }, this.options.diskIntervalMs);
69
+ if (typeof this.diskTimer.unref === 'function') this.diskTimer.unref();
41
70
  this.timer = setInterval(() => {
42
71
  void this.refreshMem(); // async, non-blocking — updates this.memPct without stalling the loop
43
72
  const sample = this.sample();
@@ -51,6 +80,7 @@ export class StatsSampler {
51
80
 
52
81
  stop() {
53
82
  if (this.timer) { clearInterval(this.timer); this.timer = null; }
83
+ if (this.diskTimer) { clearInterval(this.diskTimer); this.diskTimer = null; }
54
84
  }
55
85
 
56
86
  getStats(): StatSample[] {
@@ -68,12 +98,46 @@ export class StatsSampler {
68
98
  cpu: Math.round(cpu),
69
99
  mem: Math.round(this.memPct), // cached; refreshed async each tick (never blocks the loop)
70
100
  load: Math.round(os.loadavg()[0] * 100) / 100,
101
+ disk: this.diskPct, // cached; refreshed every diskIntervalMs — already an integer (see diskUsedPct)
102
+ diskUsedBytes: this.diskUsedBytes,
103
+ diskTotalBytes: this.diskTotalBytes,
71
104
  };
72
105
  }
73
106
 
74
107
  // Refresh the cached memory %. os.freemem() counts reclaimable memory (page cache on Linux,
75
108
  // inactive/purgeable pages on macOS) as "used", so it reads ~95-99% even when healthy — use each OS's
76
109
  // notion of *available* (free + reclaimable) instead. Async + bounded so it can NEVER stall the loop.
110
+ // Refresh the cached disk %. statfs() is the node-native stat — no `df` subprocess, same call on
111
+ // macOS and Linux. Any failure (unreadable mount, permission) leaves the previous value in place.
112
+ private async refreshDisk(): Promise<void> {
113
+ // A hung filesystem must not let refreshes PILE UP: without this guard a never-resolving statfs
114
+ // accumulates one stuck threadpool task per tick (measured: 5 in-flight at a 50ms cadence),
115
+ // which starves Bun's fs threadpool for every other async fs consumer.
116
+ if (this.diskInFlight) return;
117
+ this.diskInFlight = true;
118
+ try {
119
+ const fs = await statfs('/');
120
+ const pct = diskUsedPct(fs, process.platform);
121
+ if (pct != null) this.diskPct = pct;
122
+ const bytes = diskBytes(fs, process.platform);
123
+ if (bytes) { this.diskUsedBytes = bytes.used; this.diskTotalBytes = bytes.total; }
124
+ this.diskFailLoggedAt = 0; // a success re-arms the log, so a NEW outage is still reported
125
+ } catch (err) {
126
+ // Keep the last known value — a stale number beats a wrong 0. Never swallow silently...
127
+ // ...but never at one line per tick either: a permission-denied or vanished mount would emit
128
+ // 1440 identical warnings a day and bury everything else. Log the first failure, then at most
129
+ // hourly while it persists. (`fs.statfs` also does not exist before Bun 1.2.x, and this
130
+ // package's engines still allow >=1.0.0 — on such a host this would warn forever from boot.)
131
+ const now = Date.now();
132
+ if (now - this.diskFailLoggedAt >= DISK_FAIL_LOG_INTERVAL_MS) {
133
+ this.diskFailLoggedAt = now;
134
+ console.warn('[stats] disk refresh failed, keeping last value', err);
135
+ }
136
+ } finally {
137
+ this.diskInFlight = false;
138
+ }
139
+ }
140
+
77
141
  private async refreshMem(): Promise<void> {
78
142
  try {
79
143
  if (process.platform === 'linux') {
@@ -94,6 +158,44 @@ export class StatsSampler {
94
158
  }
95
159
  }
96
160
 
161
+ /** Percent USED of a filesystem, from a statfs() result. Exported (and platform passed in) so BOTH
162
+ * branches are testable from either OS — the Linux one cannot otherwise be exercised in dev.
163
+ * Per-platform on purpose, like refreshMem: each OS's honest notion of "how full is it".
164
+ * Linux — exactly `df`'s formula, used/(used+available), so this meter and the box's df-parsing
165
+ * watchdog report the same number (both exclude the root-reserved margin from both sides).
166
+ * macOS — APFS volumes SHARE one container, so `df /`'s per-volume "used" reads ~25% on a disk that
167
+ * is genuinely 92% full; only free-against-container-total tells the truth there.
168
+ * Rounding is CEIL, not nearest, because `df` ceils its capacity column: exact 24.6515 prints as
169
+ * `25%`, exact 3.2258 prints as `4%`. Nearest would put the meter one point BELOW df on ~half of all
170
+ * values, so a root at exact 91.2% would show 91% in emerald "fine" while the df-parsing watchdog
171
+ * (which tests df's own `92`) is already paging Slack. Ceiling on the Linux path is what makes the
172
+ * two literally the same integer. Darwin ceils too: its number is a container reading that has no df
173
+ * column to match, but rounding the same way keeps one rule for both platforms and errs toward
174
+ * reporting a disk as fuller, never emptier, than it is.
175
+ * Returns null for a nonsense stat, so the caller keeps its last known value. */
176
+ export function diskUsedPct(fs: { blocks: bigint | number; bfree: bigint | number; bavail: bigint | number }, platform: string): number | null {
177
+ const blocks = Number(fs.blocks), bfree = Number(fs.bfree), bavail = Number(fs.bavail);
178
+ if (!(blocks > 0)) return null;
179
+ const pct = platform === 'darwin'
180
+ ? 100 * (1 - bavail / blocks)
181
+ : 100 * ((blocks - bfree) / Math.max(1, blocks - bfree + bavail));
182
+ return Math.ceil(Math.max(0, Math.min(100, pct)));
183
+ }
184
+
185
+ /**
186
+ * The same per-platform definition as `diskUsedPct`, in BYTES, for the hover tooltip. Kept adjacent
187
+ * and derived from the identical numerator/denominator so the percentage and the "X of Y" can never
188
+ * describe different quantities — on darwin the denominator is the APFS container, on linux it is
189
+ * df's `used + available` (which excludes root-reserved blocks).
190
+ */
191
+ export function diskBytes(fs: { bsize: bigint | number; blocks: bigint | number; bfree: bigint | number; bavail: bigint | number }, platform: string): { used: number; total: number } | null {
192
+ const bsize = Number(fs.bsize), blocks = Number(fs.blocks), bfree = Number(fs.bfree), bavail = Number(fs.bavail);
193
+ if (!(blocks > 0) || !(bsize > 0)) return null;
194
+ const used = platform === 'darwin' ? blocks - bavail : blocks - bfree;
195
+ const total = platform === 'darwin' ? blocks : blocks - bfree + bavail;
196
+ return { used: used * bsize, total: total * bsize };
197
+ }
198
+
97
199
  function cpuTotals() {
98
200
  let idle = 0, total = 0;
99
201
  for (const c of os.cpus()) {
@@ -0,0 +1,23 @@
1
+ // Disk-meter values that BOTH halves need. Dependency-free on purpose (no node:*, no react) so the
2
+ // server can import it and vite can bundle it into the client — the only way a threshold change is
3
+ // physically one edit instead of two that can drift.
4
+
5
+ // Percent-USED thresholds for the disk meter. Kept identical to the box's Slack alerting
6
+ // (tools/ec2/box-disk-watchdog.sh in shraga-circles, BOX_DISK_WARN_PCT/BOX_DISK_CRIT_PCT) so the
7
+ // widget and the pager agree on what "in trouble" means — 92% is where that box's pushes started
8
+ // timing out. This file is the single source: the server re-exports it, the client imports it.
9
+ export const DISK_WARN_PCT = 92;
10
+ export const DISK_CRIT_PCT = 96;
11
+
12
+ /**
13
+ * Human byte size for the tooltip. Dependency-free and deliberately identical in both halves so the
14
+ * hover text and any server-side log read the same. Binary units, because that is what `df -h`
15
+ * prints and the whole point of this meter is agreeing with `df`.
16
+ */
17
+ export function formatBytes(n: number): string {
18
+ if (!Number.isFinite(n) || n < 0) return '?';
19
+ const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
20
+ let i = 0;
21
+ while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
22
+ return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
23
+ }