diff --git a/apps/basic/20260130-000000/index.tsx b/apps/basic/20260130-000000/index.tsx
index 301eab6..fd2b077 100644
--- a/apps/basic/20260130-000000/index.tsx
+++ b/apps/basic/20260130-000000/index.tsx
@@ -3,6 +3,7 @@ import { Hype } from '@because/hype'
const app = new Hype
app.get('/', c => c.html(
Hi there!
))
+app.get('/ok', c => c.text('ok'))
const apps = () => {
}
diff --git a/apps/clock/20260130-000000/index.tsx b/apps/clock/20260130-000000/index.tsx
index 2986716..5e4b9f9 100644
--- a/apps/clock/20260130-000000/index.tsx
+++ b/apps/clock/20260130-000000/index.tsx
@@ -45,6 +45,8 @@ app.get('/styles.css', c => c.text(stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
+app.get('/ok', c => c.text('ok'))
+
app.get('/', c => c.html(
diff --git a/apps/code/20260130-000000/src/server/index.tsx b/apps/code/20260130-000000/src/server/index.tsx
index e2c0e06..cd303d1 100644
--- a/apps/code/20260130-000000/src/server/index.tsx
+++ b/apps/code/20260130-000000/src/server/index.tsx
@@ -319,6 +319,8 @@ function Layout({ title, children, highlight, editable }: LayoutProps) {
)
}
+app.get('/ok', c => c.text('ok'))
+
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
diff --git a/apps/cron/20260201-000000/index.tsx b/apps/cron/20260201-000000/index.tsx
index 678eb16..b3fa2cd 100644
--- a/apps/cron/20260201-000000/index.tsx
+++ b/apps/cron/20260201-000000/index.tsx
@@ -217,6 +217,8 @@ function statusColor(job: CronJob): string {
}
// Routes
+app.get('/ok', c => c.text('ok'))
+
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
diff --git a/apps/env/20260130-000000/index.tsx b/apps/env/20260130-000000/index.tsx
index 75da411..42186fc 100644
--- a/apps/env/20260130-000000/index.tsx
+++ b/apps/env/20260130-000000/index.tsx
@@ -248,6 +248,8 @@ function appEnvPath(appName: string): string {
return join(ENV_DIR, `${appName}.env`)
}
+app.get('/ok', c => c.text('ok'))
+
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
diff --git a/apps/profile/20260130-000000/index.tsx b/apps/profile/20260130-000000/index.tsx
index 4f4f488..9213c19 100644
--- a/apps/profile/20260130-000000/index.tsx
+++ b/apps/profile/20260130-000000/index.tsx
@@ -3,5 +3,6 @@ import { Hype } from '@because/hype'
const app = new Hype
app.get('/', c => c.html(My Profile!!!
))
+app.get('/ok', c => c.text('ok'))
export default app.defaults
diff --git a/apps/todo/20260130-181927/src/server/index.tsx b/apps/todo/20260130-181927/src/server/index.tsx
index 2835e2e..92ee55c 100644
--- a/apps/todo/20260130-181927/src/server/index.tsx
+++ b/apps/todo/20260130-181927/src/server/index.tsx
@@ -190,6 +190,8 @@ function serializeTodo(todo: ParsedTodo): string {
return lines.join('\n') + '\n'
}
+app.get('/ok', c => c.text('ok'))
+
app.get('/styles.css', c => c.text(baseStyles + todoStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
diff --git a/apps/versions/20260130-000000/index.tsx b/apps/versions/20260130-000000/index.tsx
index 62a0afa..a8c9f14 100644
--- a/apps/versions/20260130-000000/index.tsx
+++ b/apps/versions/20260130-000000/index.tsx
@@ -121,6 +121,8 @@ function Layout({ title, subtitle, children }: LayoutProps) {
)
}
+app.get('/ok', c => c.text('ok'))
+
app.get('/styles.css', c => c.text(baseStyles + stylesToCSS(), 200, {
'Content-Type': 'text/css; charset=utf-8',
}))
diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts
index e802e72..d36c55a 100644
--- a/src/cli/commands/index.ts
+++ b/src/cli/commands/index.ts
@@ -12,4 +12,5 @@ export {
startApp,
stopApp,
} from './manage'
+export { statsApp } from './stats'
export { cleanApp, diffApp, getApp, pullApp, pushApp, rollbackApp, stashApp, stashListApp, stashPopApp, statusApp, syncApp, versionsApp } from './sync'
diff --git a/src/cli/commands/stats.ts b/src/cli/commands/stats.ts
new file mode 100644
index 0000000..646c075
--- /dev/null
+++ b/src/cli/commands/stats.ts
@@ -0,0 +1,103 @@
+import color from 'kleur'
+import { get } from '../http'
+import { resolveAppName } from '../name'
+
+interface AppStats {
+ name: string
+ state: string
+ port?: number
+ pid?: number
+ cpu?: number
+ memory?: number
+ rss?: number
+ tool?: boolean
+}
+
+function formatRss(kb?: number): string {
+ if (kb === undefined) return '-'
+ if (kb < 1024) return `${kb} KB`
+ if (kb < 1024 * 1024) return `${(kb / 1024).toFixed(1)} MB`
+ return `${(kb / 1024 / 1024).toFixed(2)} GB`
+}
+
+function formatPercent(value?: number): string {
+ if (value === undefined) return '-'
+ return `${value.toFixed(1)}%`
+}
+
+function pad(str: string, len: number, right = false): string {
+ if (right) return str.padStart(len)
+ return str.padEnd(len)
+}
+
+export async function statsApp(arg?: string) {
+ // If arg is provided, show stats for that app only
+ if (arg) {
+ const name = resolveAppName(arg)
+ if (!name) return
+
+ const stats: AppStats | undefined = await get(`/api/tools/stats/api/stats/${name}`)
+ if (!stats) {
+ console.error(`App not found: ${name}`)
+ return
+ }
+
+ console.log(`${color.bold(stats.name)} ${stats.tool ? color.gray('[tool]') : ''}`)
+ console.log(` State: ${stats.state}`)
+ if (stats.pid) console.log(` PID: ${stats.pid}`)
+ if (stats.port) console.log(` Port: ${stats.port}`)
+ if (stats.cpu !== undefined) console.log(` CPU: ${formatPercent(stats.cpu)}`)
+ if (stats.memory !== undefined) console.log(` Memory: ${formatPercent(stats.memory)}`)
+ if (stats.rss !== undefined) console.log(` RSS: ${formatRss(stats.rss)}`)
+ return
+ }
+
+ // Show stats for all apps
+ const stats: AppStats[] | undefined = await get('/api/tools/stats/api/stats')
+ if (!stats || stats.length === 0) {
+ console.log('No apps found')
+ return
+ }
+
+ // Sort: running first, then by name
+ stats.sort((a, b) => {
+ if (a.state === 'running' && b.state !== 'running') return -1
+ if (a.state !== 'running' && b.state === 'running') return 1
+ return a.name.localeCompare(b.name)
+ })
+
+ // Calculate column widths
+ const nameWidth = Math.max(4, ...stats.map(s => s.name.length + (s.tool ? 7 : 0)))
+ const stateWidth = Math.max(5, ...stats.map(s => s.state.length))
+
+ // Header
+ console.log(
+ color.gray(
+ `${pad('NAME', nameWidth)} ${pad('STATE', stateWidth)} ${pad('PID', 7, true)} ${pad('CPU', 7, true)} ${pad('MEM', 7, true)} ${pad('RSS', 10, true)}`
+ )
+ )
+
+ // Rows
+ for (const s of stats) {
+ const name = s.tool ? `${s.name} ${color.gray('[tool]')}` : s.name
+ const stateColor = s.state === 'running' ? color.green : s.state === 'invalid' ? color.red : color.gray
+ const state = stateColor(s.state)
+
+ const pid = s.pid ? String(s.pid) : '-'
+ const cpu = formatPercent(s.cpu)
+ const mem = formatPercent(s.memory)
+ const rss = formatRss(s.rss)
+
+ console.log(
+ `${pad(name, nameWidth)} ${pad(state, stateWidth)} ${pad(pid, 7, true)} ${pad(cpu, 7, true)} ${pad(mem, 7, true)} ${pad(rss, 10, true)}`
+ )
+ }
+
+ // Summary
+ const running = stats.filter(s => s.state === 'running')
+ const totalCpu = running.reduce((sum, s) => sum + (s.cpu ?? 0), 0)
+ const totalRss = running.reduce((sum, s) => sum + (s.rss ?? 0), 0)
+
+ console.log()
+ console.log(color.gray(`${running.length} running, ${formatPercent(totalCpu)} CPU, ${formatRss(totalRss)} total`))
+}
diff --git a/src/cli/setup.ts b/src/cli/setup.ts
index 8f53d21..4e88969 100644
--- a/src/cli/setup.ts
+++ b/src/cli/setup.ts
@@ -24,6 +24,7 @@ import {
stashListApp,
stashPopApp,
startApp,
+ statsApp,
statusApp,
stopApp,
syncApp,
@@ -109,6 +110,12 @@ program
.option('-g, --grep ', 'filter logs by pattern')
.action(logApp)
+program
+ .command('stats')
+ .description('Show CPU and memory stats for apps')
+ .argument('[name]', 'app name (uses current directory if omitted)')
+ .action(statsApp)
+
program
.command('open')
.description('Open an app in browser')
diff --git a/src/server/apps.ts b/src/server/apps.ts
index e749955..c032be7 100644
--- a/src/server/apps.ts
+++ b/src/server/apps.ts
@@ -715,7 +715,7 @@ function startHealthChecks(app: App, port: number) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT)
- const response = await fetch(`http://localhost:${port}/`, {
+ const response = await fetch(`http://localhost:${port}/ok`, {
signal: controller.signal,
})
diff --git a/templates/bare/index.tsx b/templates/bare/index.tsx
index 724f3a9..e836863 100644
--- a/templates/bare/index.tsx
+++ b/templates/bare/index.tsx
@@ -3,5 +3,6 @@ import { Hype } from '@because/hype'
const app = new Hype()
app.get('/', c => c.text('$$APP_NAME$$'))
+app.get('/ok', c => c.text('ok'))
export default app.defaults
diff --git a/templates/spa/src/server/index.tsx b/templates/spa/src/server/index.tsx
index 99049e2..55041a0 100644
--- a/templates/spa/src/server/index.tsx
+++ b/templates/spa/src/server/index.tsx
@@ -4,5 +4,6 @@ const app = new Hype({ layout: false })
// custom routes go here
// app.get("/my-custom-routes", (c) => c.text("wild, wild stuff"))
+app.get('/ok', c => c.text('ok'))
export default app.defaults