@chrysb/alphaclaw 0.3.0 → 0.3.2-beta.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 chrysb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,177 @@
1
+ <p align="center">
2
+ <img width="771" height="339" alt="image" src="https://github.com/user-attachments/assets/b96b45ab-52f2-4010-bfbe-c640e66b0f36" />
3
+ </p>
4
+ <h1 align="center">AlphaClaw</h1>
5
+ <p align="center">
6
+ <strong>The ops layer for OpenClaw. Deploy in minutes. Stay running for months.</strong><br>
7
+ <strong>Observability. Reliability. Agent discipline. Zero SSH rescue missions.</strong>
8
+ </p>
9
+
10
+ <p align="center">
11
+ <a href="https://github.com/chrysb/alphaclaw/actions/workflows/ci.yml"><img src="https://github.com/chrysb/alphaclaw/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
12
+ <a href="https://www.npmjs.com/package/@chrysb/alphaclaw"><img src="https://img.shields.io/npm/v/@chrysb/alphaclaw" alt="npm version" /></a>
13
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT" /></a>
14
+ </p>
15
+
16
+ <p align="center">AlphaClaw wraps <a href="https://github.com/openclaw/openclaw">OpenClaw</a> with a convenient setup wizard, self-healing watchdog, Git-backed rollback, and full browser-based observability. Ships with anti-drift prompt hardening to keep your agent disciplined, and simplifies integrations (e.g. Google Workspace, Telegram Topics) so you can manage everything from one UI instead of config files.</p>
17
+
18
+ <p align="center"><em>First deploy to first message in under five minutes.</em></p>
19
+
20
+ <p align="center">
21
+ <a href="https://railway.com/deploy/openclaw-fast-start?referralCode=jcFhp_&utm_medium=integration&utm_source=template&utm_campaign=generic"><img src="https://railway.com/button.svg" alt="Deploy on Railway" /></a>
22
+ <a href="https://render.com/deploy?repo=https://github.com/chrysb/openclaw-render-template"><img src="https://render.com/images/deploy-to-render-button.svg" alt="Deploy to Render" /></a>
23
+ </p>
24
+
25
+ > **Platform:** AlphaClaw currently targets Docker/Linux deployments. macOS local development is not yet supported.
26
+
27
+ ## Features
28
+
29
+ - **Setup UI:** Password-protected web dashboard for onboarding, configuration, and day-to-day management.
30
+ - **Guided Onboarding:** Step-by-step setup wizard — model selection, provider credentials, GitHub repo, channel pairing.
31
+ - **Gateway Manager:** Spawns, monitors, restarts, and proxies the OpenClaw gateway as a managed child process.
32
+ - **Watchdog:** Crash detection, crash-loop recovery, auto-repair (`openclaw doctor --fix`), and Telegram/Discord notifications.
33
+ - **Channel Orchestration:** Telegram and Discord bot pairing, credential sync, and a guided wizard for splitting Telegram into multi-threaded topic groups as your usage grows.
34
+ - **Webhooks:** Named webhook endpoints with per-hook transform modules, request logging, and payload inspection.
35
+ - **Google Workspace:** OAuth integration for Gmail, Calendar, Drive, Docs, Sheets, Tasks, Contacts, and Meet.
36
+ - **Prompt Hardening:** Ships anti-drift bootstrap prompts (`AGENTS.md`, `TOOLS.md`) injected into your agent's system prompt on every message — enforcing safe practices, commit discipline, and change summaries out of the box.
37
+ - **Git Sync:** Automatic hourly commits of your OpenClaw workspace to GitHub with configurable cron schedule. Combined with prompt hardening, every agent action is version-controlled and auditable.
38
+ - **Version Management:** In-place updates for both AlphaClaw and OpenClaw with changelog review and one-click apply.
39
+ - **Codex OAuth:** Built-in PKCE flow for OpenAI Codex CLI model access.
40
+
41
+ ## Why AlphaClaw
42
+
43
+ - **Zero to production in one deploy:** Railway/Render templates ship a complete stack — no manual gateway setup.
44
+ - **Self-healing:** Watchdog detects crashes, enters repair mode, relaunches the gateway, and notifies you.
45
+ - **Everything in the browser:** No SSH, no config files to hand-edit, no CLI required after first deploy.
46
+ - **Stays out of the way:** AlphaClaw manages infrastructure; OpenClaw handles the AI.
47
+
48
+ ## No Lock-in. Eject Anytime.
49
+
50
+ AlphaClaw simply wraps OpenClaw, it's not a dependency. Remove AlphaClaw and your agent keeps running. Nothing proprietary, nothing to migrate.
51
+
52
+ ## Quick Start
53
+
54
+ ### Deploy (recommended)
55
+
56
+ [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/openclaw-fast-start?referralCode=jcFhp_&utm_medium=integration&utm_source=template&utm_campaign=generic)
57
+ [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/chrysb/openclaw-render-template)
58
+
59
+ Set `SETUP_PASSWORD` at deploy time and visit your deployment URL. The welcome wizard handles the rest.
60
+
61
+ ### Local / Docker
62
+
63
+ ```bash
64
+ npm install @chrysb/alphaclaw
65
+ npx alphaclaw start
66
+ ```
67
+
68
+ Or with Docker:
69
+
70
+ ```dockerfile
71
+ FROM node:22-slim
72
+ RUN apt-get update && apt-get install -y git curl procps cron && rm -rf /var/lib/apt/lists/*
73
+ WORKDIR /app
74
+ COPY package.json ./
75
+ RUN npm install --omit=dev
76
+ ENV PATH="/app/node_modules/.bin:$PATH"
77
+ ENV ALPHACLAW_ROOT_DIR=/data
78
+ EXPOSE 3000
79
+ CMD ["alphaclaw", "start"]
80
+ ```
81
+
82
+ ## Setup UI
83
+
84
+ | Tab | What it manages |
85
+ | ------------- | ---------------------------------------------------------------------------------------------------------- |
86
+ | **General** | Gateway status, channel health, pending pairings, Google Workspace, repo sync schedule, OpenClaw dashboard |
87
+ | **Watchdog** | Health monitoring, crash-loop status, auto-repair toggle, notifications toggle, event log, live log tail |
88
+ | **Providers** | AI provider credentials (Anthropic, OpenAI, Gemini, Mistral, Voyage, Groq, Deepgram) and model selection |
89
+ | **Envars** | Environment variables — view, edit, add — with gateway restart prompts |
90
+ | **Webhooks** | Webhook endpoints, transform modules, request history, payload inspection |
91
+
92
+ ## CLI
93
+
94
+ | Command | Description |
95
+ | ---------------------------------------------------------- | --------------------------------------------- |
96
+ | `alphaclaw start` | Start the server (Setup UI + gateway manager) |
97
+ | `alphaclaw git-sync -m "message"` | Commit and push the OpenClaw workspace |
98
+ | `alphaclaw telegram topic add --thread <id> --name <text>` | Register a Telegram topic mapping |
99
+ | `alphaclaw version` | Print version |
100
+ | `alphaclaw help` | Show help |
101
+
102
+ ## Architecture
103
+
104
+ ```mermaid
105
+ graph TD
106
+ subgraph AlphaClaw
107
+ UI["Setup UI<br/><small>Preact + htm + Wouter</small>"]
108
+ WD["Watchdog<br/><small>Crash recovery · Notifications</small>"]
109
+ WH["Webhooks<br/><small>Transforms · Request logging</small>"]
110
+ UI --> API
111
+ WD --> API
112
+ WH --> API
113
+ API["Express Server<br/><small>JSON APIs · Auth · Proxy</small>"]
114
+ end
115
+
116
+ API -- "proxy" --> GW["OpenClaw Gateway<br/><small>Child process · 127.0.0.1:18789</small>"]
117
+ GW --> DATA["ALPHACLAW_ROOT_DIR<br/><small>.openclaw/ · .env · logs · SQLite</small>"]
118
+ ```
119
+
120
+ ## Watchdog
121
+
122
+ The built-in watchdog monitors gateway health and recovers from failures automatically.
123
+
124
+ | Capability | Details |
125
+ | --------------------------- | -------------------------------------------------------------- |
126
+ | **Health checks** | Periodic `openclaw health` with configurable interval |
127
+ | **Crash detection** | Listens for gateway exit events |
128
+ | **Crash-loop detection** | Threshold-based (default: 3 crashes in 300s) |
129
+ | **Auto-repair** | Runs `openclaw doctor --fix --yes`, relaunches gateway |
130
+ | **Notifications** | Telegram and Discord alerts for crashes, repairs, and recovery |
131
+ | **Event log** | SQLite-backed incident history with API and UI access |
132
+
133
+ ## Environment Variables
134
+
135
+ | Variable | Required | Description |
136
+ | --------------------------------- | -------- | -------------------------------------------------- |
137
+ | `SETUP_PASSWORD` | Yes | Password for the Setup UI |
138
+ | `OPENCLAW_GATEWAY_TOKEN` | Auto | Gateway auth token (auto-generated if unset) |
139
+ | `GITHUB_TOKEN` | Yes | GitHub PAT for workspace repo |
140
+ | `GITHUB_WORKSPACE_REPO` | Yes | GitHub repo for workspace sync (e.g. `owner/repo`) |
141
+ | `TELEGRAM_BOT_TOKEN` | Optional | Telegram bot token |
142
+ | `DISCORD_BOT_TOKEN` | Optional | Discord bot token |
143
+ | `WATCHDOG_AUTO_REPAIR` | Optional | Enable auto-repair on crash (`true`/`false`) |
144
+ | `WATCHDOG_NOTIFICATIONS_DISABLED` | Optional | Disable watchdog notifications (`true`/`false`) |
145
+ | `PORT` | Optional | Server port (default `3000`) |
146
+ | `ALPHACLAW_ROOT_DIR` | Optional | Data directory (default `/data`) |
147
+ | `TRUST_PROXY_HOPS` | Optional | Trust proxy hop count for correct client IP |
148
+
149
+ ## Security Notes
150
+
151
+ AlphaClaw is a convenience wrapper — it intentionally trades some of OpenClaw's default hardening for ease of setup. You should understand what's different:
152
+
153
+ | Area | What AlphaClaw does | Trade-off |
154
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
155
+ | **Setup password** | All gateway access is gated behind a single `SETUP_PASSWORD`. Brute-force protection is built in (exponential backoff lockout). | Simpler than OpenClaw's pairing code flow, but the password must be strong. |
156
+ | **One-click pairing** | Channel pairings (Telegram/Discord) can be approved from the Setup UI instead of the CLI. | No terminal access required, but anyone with the setup password can approve pairings. |
157
+ | **Auto CLI approval** | The first CLI device pairing is auto-approved so you can connect without a second screen. Subsequent requests appear in the UI. | Removes the manual pairing step for the initial CLI connection. |
158
+ | **Query-string tokens** | Webhook URLs support `?token=<WEBHOOK_TOKEN>` for providers that don't support `Authorization` headers. Warnings are shown in the UI. | Tokens may appear in server logs and referrer headers. Use header auth when your provider supports it. |
159
+ | **Gateway token** | `OPENCLAW_GATEWAY_TOKEN` is auto-generated and injected into the environment so the proxy can authenticate with the gateway. | The token lives in the `.env` file on the server — standard for managed deployments but worth noting. |
160
+
161
+ If you need OpenClaw's full security posture (manual pairing codes, no query-string tokens, no auto-approval), use OpenClaw directly without AlphaClaw.
162
+
163
+ ## Development
164
+
165
+ ```bash
166
+ npm install
167
+ npm test # Full suite (90 tests)
168
+ npm run test:watchdog # Watchdog-focused suite (14 tests)
169
+ npm run test:watch # Watch mode
170
+ npm run test:coverage # Coverage report
171
+ ```
172
+
173
+ **Requirements:** Node.js ≥ 22.12.0
174
+
175
+ ## License
176
+
177
+ MIT
@@ -48,6 +48,53 @@ body::before {
48
48
  color: var(--text-muted);
49
49
  }
50
50
 
51
+ /* Shared collapsible history rows (incidents, webhook requests). */
52
+ .ac-history-list {
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: 8px;
56
+ }
57
+
58
+ .ac-history-item {
59
+ border: 1px solid var(--panel-border-contrast);
60
+ border-radius: 10px;
61
+ background: rgba(0, 0, 0, 0.12);
62
+ }
63
+
64
+ .ac-history-summary {
65
+ cursor: pointer;
66
+ list-style: none;
67
+ padding: 8px 10px;
68
+ color: #d1d5db;
69
+ }
70
+
71
+ .ac-history-summary::-webkit-details-marker {
72
+ display: none;
73
+ }
74
+
75
+ .ac-history-summary-row {
76
+ display: flex;
77
+ align-items: center;
78
+ justify-content: space-between;
79
+ gap: 8px;
80
+ }
81
+
82
+ .ac-history-toggle {
83
+ color: var(--text-muted);
84
+ transition: transform 0.15s ease, color 0.15s ease;
85
+ }
86
+
87
+ .ac-history-item[open] .ac-history-toggle {
88
+ transform: rotate(90deg);
89
+ color: #d1d5db;
90
+ }
91
+
92
+ .ac-history-body {
93
+ margin: 4px 10px 10px;
94
+ padding-top: 8px;
95
+ border-top: 1px solid var(--panel-border-contrast);
96
+ }
97
+
51
98
  /* Unified panel treatment across tabs/pages. */
52
99
  .bg-surface {
53
100
  background: var(--panel-bg-contrast) !important;
@@ -4,6 +4,7 @@ import htm from "https://esm.sh/htm";
4
4
  import {
5
5
  fetchWatchdogEvents,
6
6
  fetchWatchdogLogs,
7
+ fetchWatchdogResources,
7
8
  fetchWatchdogSettings,
8
9
  updateWatchdogSettings,
9
10
  triggerWatchdogRepair,
@@ -16,9 +17,96 @@ import { showToast } from "./toast.js";
16
17
 
17
18
  const html = htm.bind(h);
18
19
 
20
+ const formatBytes = (bytes) => {
21
+ if (bytes == null) return "—";
22
+ if (bytes < 1024) return `${bytes} B`;
23
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
24
+ if (bytes < 1024 * 1024 * 1024)
25
+ return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
26
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
27
+ };
28
+
29
+ const barColor = (percent) => {
30
+ if (percent == null) return "bg-gray-600";
31
+ if (percent >= 90) return "bg-red-500";
32
+ if (percent >= 70) return "bg-yellow-400";
33
+ return "bg-cyan-400";
34
+ };
35
+
36
+ const ResourceBar = ({
37
+ label,
38
+ percent,
39
+ detail,
40
+ segments = null,
41
+ expanded = false,
42
+ onToggle = null,
43
+ }) => html`
44
+ <div
45
+ class=${onToggle ? "cursor-pointer group" : ""}
46
+ onclick=${onToggle || undefined}
47
+ >
48
+ <span
49
+ class=${`text-xs text-gray-400 ${onToggle ? "group-hover:text-gray-200 transition-colors" : ""}`}
50
+ >${label}</span
51
+ >
52
+ <div
53
+ class=${`h-1.5 w-full bg-white/5 rounded-full overflow-hidden mt-1.5 flex ${onToggle ? "group-hover:bg-white/10 transition-colors" : ""}`}
54
+ >
55
+ ${expanded && segments
56
+ ? segments.map(
57
+ (seg) => html`
58
+ <div
59
+ class="h-full"
60
+ style=${{
61
+ width: `${Math.min(100, seg.percent ?? 0)}%`,
62
+ backgroundColor: seg.color,
63
+ transition:
64
+ "width 0.8s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.5s ease",
65
+ }}
66
+ ></div>
67
+ `,
68
+ )
69
+ : html`
70
+ <div
71
+ class=${`h-full rounded-full ${barColor(percent)}`}
72
+ style=${{
73
+ width: `${Math.min(100, percent ?? 0)}%`,
74
+ transition:
75
+ "width 0.8s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.5s ease",
76
+ }}
77
+ ></div>
78
+ `}
79
+ </div>
80
+ <div class="flex flex-wrap items-center gap-x-3 mt-2.5">
81
+ <span class="text-xs text-gray-500 font-mono flex-1">${detail}</span>
82
+ ${expanded &&
83
+ segments &&
84
+ segments
85
+ .filter((s) => s.label)
86
+ .map(
87
+ (seg) => html`
88
+ <span
89
+ class="inline-flex items-center gap-1 text-xs text-gray-500 font-mono"
90
+ >
91
+ <span
92
+ class="inline-block w-1.5 h-1.5 rounded-full"
93
+ style=${{ backgroundColor: seg.color }}
94
+ ></span>
95
+ ${seg.label}
96
+ </span>
97
+ `,
98
+ )}
99
+ </div>
100
+ </div>
101
+ `;
102
+
19
103
  const getIncidentStatusTone = (event) => {
20
- const eventType = String(event?.eventType || "").trim().toLowerCase();
21
- const status = String(event?.status || "").trim().toLowerCase();
104
+ const eventType = String(event?.eventType || "")
105
+ .trim()
106
+ .toLowerCase();
107
+ const status = String(event?.status || "")
108
+ .trim()
109
+ .toLowerCase();
22
110
  if (status === "failed") {
23
111
  return {
24
112
  dotClass: "bg-red-500/90",
@@ -53,6 +141,8 @@ export const WatchdogTab = ({
53
141
  restartSignal = 0,
54
142
  }) => {
55
143
  const eventsPoll = usePolling(() => fetchWatchdogEvents(20), 15000);
144
+ const resourcesPoll = usePolling(() => fetchWatchdogResources(), 5000);
145
+ const [memoryExpanded, setMemoryExpanded] = useState(false);
56
146
  const [settings, setSettings] = useState({
57
147
  autoRepair: false,
58
148
  notificationsEnabled: true,
@@ -66,7 +156,8 @@ export const WatchdogTab = ({
66
156
 
67
157
  const currentWatchdogStatus = watchdogStatus || {};
68
158
  const events = eventsPoll.data?.events || [];
69
- const isRepairInProgress = repairing || !!currentWatchdogStatus?.operationInProgress;
159
+ const isRepairInProgress =
160
+ repairing || !!currentWatchdogStatus?.operationInProgress;
70
161
 
71
162
  useEffect(() => {
72
163
  let active = true;
@@ -172,7 +263,10 @@ export const WatchdogTab = ({
172
263
  },
173
264
  );
174
265
  onRefreshStatuses();
175
- showToast(`Notifications ${nextValue ? "enabled" : "disabled"}`, "success");
266
+ showToast(
267
+ `Notifications ${nextValue ? "enabled" : "disabled"}`,
268
+ "success",
269
+ );
176
270
  } catch (err) {
177
271
  showToast(err.message || "Could not update notifications", "error");
178
272
  } finally {
@@ -210,6 +304,80 @@ export const WatchdogTab = ({
210
304
  repairing=${isRepairInProgress}
211
305
  />
212
306
 
307
+ ${(() => {
308
+ const r = resourcesPoll.data?.resources;
309
+ if (!r) return null;
310
+ return html`
311
+ <div class="bg-surface border border-border rounded-xl p-4">
312
+ ${memoryExpanded
313
+ ? html`
314
+ <${ResourceBar}
315
+ label="Memory"
316
+ detail=${`${formatBytes(r.memory?.usedBytes)} / ${formatBytes(r.memory?.totalBytes)}`}
317
+ percent=${r.memory?.percent}
318
+ expanded=${true}
319
+ onToggle=${() => setMemoryExpanded(false)}
320
+ segments=${(() => {
321
+ const p = r.processes;
322
+ const total = r.memory?.totalBytes;
323
+ const used = r.memory?.usedBytes;
324
+ if (!p || !total || !used) return null;
325
+ const segs = [];
326
+ let tracked = 0;
327
+ if (p.gateway?.rssBytes != null) {
328
+ tracked += p.gateway.rssBytes;
329
+ segs.push({
330
+ percent: (p.gateway.rssBytes / total) * 100,
331
+ color: "#22d3ee",
332
+ label: `Gateway ${formatBytes(p.gateway.rssBytes)}`,
333
+ });
334
+ }
335
+ if (p.alphaclaw?.rssBytes != null) {
336
+ tracked += p.alphaclaw.rssBytes;
337
+ segs.push({
338
+ percent: (p.alphaclaw.rssBytes / total) * 100,
339
+ color: "#a78bfa",
340
+ label: `AlphaClaw ${formatBytes(p.alphaclaw.rssBytes)}`,
341
+ });
342
+ }
343
+ const other = Math.max(0, used - tracked);
344
+ if (other > 0) {
345
+ segs.push({
346
+ percent: (other / total) * 100,
347
+ color: "#4b5563",
348
+ label: `Other ${formatBytes(other)}`,
349
+ });
350
+ }
351
+ return segs.length ? segs : null;
352
+ })()}
353
+ />
354
+ `
355
+ : html`
356
+ <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
357
+ <${ResourceBar}
358
+ label="Memory"
359
+ percent=${r.memory?.percent}
360
+ detail=${`${formatBytes(r.memory?.usedBytes)} / ${formatBytes(r.memory?.totalBytes)}`}
361
+ onToggle=${() => setMemoryExpanded(true)}
362
+ />
363
+ <${ResourceBar}
364
+ label="Disk"
365
+ percent=${r.disk?.percent}
366
+ detail=${`${formatBytes(r.disk?.usedBytes)} / ${formatBytes(r.disk?.totalBytes)}`}
367
+ />
368
+ <${ResourceBar}
369
+ label=${`CPU${r.cpu?.cores ? ` (${r.cpu.cores} core${r.cpu.cores > 1 ? "s" : ""})` : ""}`}
370
+ percent=${r.cpu?.percent}
371
+ detail=${r.cpu?.percent != null
372
+ ? `${r.cpu.percent}%`
373
+ : "—"}
374
+ />
375
+ </div>
376
+ `}
377
+ </div>
378
+ `;
379
+ })()}
380
+
213
381
  <div class="bg-surface border border-border rounded-xl p-4">
214
382
  <div class="flex items-center justify-between gap-3">
215
383
  <div class="inline-flex items-center gap-2 text-xs text-gray-400">
@@ -229,7 +397,7 @@ export const WatchdogTab = ({
229
397
  <div class="inline-flex items-center gap-2 text-xs text-gray-400">
230
398
  <span>Notifications</span>
231
399
  <${InfoTooltip}
232
- text="Sends Telegram notices for watchdog alerts and auto-repair outcomes."
400
+ text="Sends channel notices for watchdog alerts and auto-repair outcomes."
233
401
  />
234
402
  </div>
235
403
  <${ToggleSwitch}
@@ -243,7 +411,7 @@ export const WatchdogTab = ({
243
411
 
244
412
  <div class="bg-surface border border-border rounded-xl p-4">
245
413
  <div class="flex items-center justify-between gap-2 mb-3">
246
- <h2 class="font-semibold text-sm">Logs</h2>
414
+ <h2 class="card-label">Logs</h2>
247
415
  <label class="inline-flex items-center gap-2 text-xs text-gray-400">
248
416
  <input
249
417
  type="checkbox"
@@ -256,12 +424,14 @@ export const WatchdogTab = ({
256
424
  <pre
257
425
  ref=${logsRef}
258
426
  class="bg-black/40 border border-border rounded-lg p-3 h-72 overflow-auto text-xs text-gray-300 whitespace-pre-wrap break-words"
259
- >${loadingLogs ? "Loading logs..." : logs || "No logs yet."}</pre>
427
+ >
428
+ ${loadingLogs ? "Loading logs..." : logs || "No logs yet."}</pre
429
+ >
260
430
  </div>
261
431
 
262
432
  <div class="bg-surface border border-border rounded-xl p-4">
263
433
  <div class="flex items-center justify-between gap-2 mb-3">
264
- <h2 class="font-semibold text-sm">Recent incidents</h2>
434
+ <h2 class="card-label">Recent incidents</h2>
265
435
  <button
266
436
  class="text-xs text-gray-400 hover:text-gray-200"
267
437
  onclick=${() => eventsPoll.refresh()}
@@ -269,42 +439,46 @@ export const WatchdogTab = ({
269
439
  Refresh
270
440
  </button>
271
441
  </div>
272
- <div class="space-y-2">
442
+ <div class="ac-history-list">
273
443
  ${events.length === 0 &&
274
444
  html`<p class="text-xs text-gray-500">No incidents recorded.</p>`}
275
- ${events.map(
276
- (event) => {
277
- const tone = getIncidentStatusTone(event);
278
- return html`
279
- <details class="border border-border rounded-lg p-2">
280
- <summary class="cursor-pointer text-xs text-gray-300 list-none [&::-webkit-details-marker]:hidden">
281
- <div class="flex items-center justify-between gap-2">
282
- <span class="inline-flex items-center gap-2 min-w-0">
283
- <span class="text-gray-500 shrink-0" aria-hidden="true">▸</span>
284
- <span class="truncate">
285
- ${event.createdAt || ""} · ${event.eventType || "event"} · ${event.status ||
286
- "unknown"}
287
- </span>
288
- </span>
445
+ ${events.map((event) => {
446
+ const tone = getIncidentStatusTone(event);
447
+ return html`
448
+ <details class="ac-history-item">
449
+ <summary class="ac-history-summary">
450
+ <div class="ac-history-summary-row">
451
+ <span class="inline-flex items-center gap-2 min-w-0">
289
452
  <span
290
- class=${`h-2.5 w-2.5 shrink-0 rounded-full ${tone.dotClass}`}
291
- title=${tone.label}
292
- aria-label=${tone.label}
293
- ></span>
294
- </div>
295
- </summary>
296
- <div class="mt-2 text-xs text-gray-400">
297
- <div>Source: ${event.source || "unknown"}</div>
298
- <pre class="mt-2 bg-black/30 rounded p-2 whitespace-pre-wrap break-words"
299
- >${typeof event.details === "string"
300
- ? event.details
301
- : JSON.stringify(event.details || {}, null, 2)}</pre
302
- >
453
+ class="ac-history-toggle shrink-0"
454
+ aria-hidden="true"
455
+ >▸</span
456
+ >
457
+ <span class="truncate">
458
+ ${event.createdAt || ""} · ${event.eventType || "event"}
459
+ · ${event.status || "unknown"}
460
+ </span>
461
+ </span>
462
+ <span
463
+ class=${`h-2.5 w-2.5 shrink-0 rounded-full ${tone.dotClass}`}
464
+ title=${tone.label}
465
+ aria-label=${tone.label}
466
+ ></span>
303
467
  </div>
304
- </details>
305
- `;
306
- },
307
- )}
468
+ </summary>
469
+ <div class="ac-history-body text-xs text-gray-400">
470
+ <div>Source: ${event.source || "unknown"}</div>
471
+ <pre
472
+ class="mt-2 bg-black/30 rounded p-2 whitespace-pre-wrap break-words"
473
+ >
474
+ ${typeof event.details === "string"
475
+ ? event.details
476
+ : JSON.stringify(event.details || {}, null, 2)}</pre
477
+ >
478
+ </div>
479
+ </details>
480
+ `;
481
+ })}
308
482
  </div>
309
483
  </div>
310
484
  </div>
@@ -48,10 +48,10 @@ const formatLastReceived = (value) => {
48
48
 
49
49
  const formatBytes = (size) => {
50
50
  const bytes = Number(size || 0);
51
- if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
52
- if (bytes < 1024) return `${bytes} B`;
53
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
54
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
51
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0B";
52
+ if (bytes < 1024) return `${bytes}B`;
53
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
54
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
55
55
  };
56
56
 
57
57
  const healthClassName = (health) => {
@@ -60,10 +60,24 @@ const healthClassName = (health) => {
60
60
  return "bg-green-500";
61
61
  };
62
62
 
63
- const statusBadgeClass = (status) =>
64
- status === "success"
65
- ? "bg-green-500/10 text-green-300 border border-green-500/30"
66
- : "bg-red-500/10 text-red-300 border border-red-500/30";
63
+ const getRequestStatusTone = (status) => {
64
+ if (status === "success") {
65
+ return {
66
+ dotClass: "bg-green-500/90",
67
+ textClass: "text-green-500/90",
68
+ };
69
+ }
70
+ if (status === "error") {
71
+ return {
72
+ dotClass: "bg-red-500/90",
73
+ textClass: "text-red-500/90",
74
+ };
75
+ }
76
+ return {
77
+ dotClass: "bg-gray-500/70",
78
+ textClass: "text-gray-400",
79
+ };
80
+ };
67
81
 
68
82
  const jsonPretty = (value) => {
69
83
  if (typeof value === "string") {
@@ -349,11 +363,11 @@ export const Webhooks = ({
349
363
  onRestartRequired,
350
364
  ]);
351
365
 
352
- const toggleRow = useCallback((id) => {
366
+ const handleRequestRowToggle = useCallback((id, isOpen) => {
353
367
  setExpandedRows((prev) => {
354
368
  const next = new Set(prev);
355
- if (next.has(id)) next.delete(id);
356
- else next.add(id);
369
+ if (isOpen) next.add(id);
370
+ else next.delete(id);
357
371
  return next;
358
372
  });
359
373
  }, []);
@@ -707,7 +721,7 @@ export const Webhooks = ({
707
721
  class="bg-surface border border-border rounded-xl p-4 space-y-3"
708
722
  >
709
723
  <div class="flex items-center justify-between gap-3">
710
- <h3 class="font-semibold text-sm">Request history</h3>
724
+ <h3 class="card-label">Request history</h3>
711
725
  <div class="flex items-center gap-2">
712
726
  ${kStatusFilters.map(
713
727
  (filter) => html`
@@ -734,37 +748,51 @@ export const Webhooks = ({
734
748
  No requests logged yet.
735
749
  </p>`
736
750
  : html`
737
- <div class="divide-y divide-border">
751
+ <div class="ac-history-list">
738
752
  ${requests.map(
739
- (item) => html`
740
- <div class="py-2">
741
- <button
742
- class="w-full text-left"
743
- onclick=${() => toggleRow(item.id)}
744
- >
745
- <div
746
- class="flex items-center justify-between gap-3"
747
- >
748
- <div class="text-xs text-gray-300">
749
- ${formatLastReceived(item.createdAt)}
750
- </div>
751
- <div class="flex items-center gap-2">
753
+ (item) => {
754
+ const statusTone = getRequestStatusTone(item.status);
755
+ return html`
756
+ <details
757
+ class="ac-history-item"
758
+ open=${expandedRows.has(item.id)}
759
+ ontoggle=${(e) =>
760
+ handleRequestRowToggle(
761
+ item.id,
762
+ !!e.currentTarget?.open,
763
+ )}
764
+ >
765
+ <summary class="ac-history-summary">
766
+ <div class="ac-history-summary-row">
767
+ <span class="inline-flex items-center gap-2 min-w-0">
768
+ <span class="ac-history-toggle shrink-0" aria-hidden="true"
769
+ >▸</span
770
+ >
771
+ <span class="truncate text-xs text-gray-300">
772
+ ${formatLastReceived(item.createdAt)}
773
+ </span>
774
+ </span>
775
+ <span class="inline-flex items-center gap-2 shrink-0">
752
776
  <span class="text-xs text-gray-500"
753
777
  >${formatBytes(item.payloadSize)}</span
754
778
  >
755
779
  <span
756
- class="text-[11px] px-2 py-0.5 rounded ${statusBadgeClass(
757
- item.status,
758
- )}"
780
+ class=${`text-xs font-medium ${statusTone.textClass}`}
781
+ >${item.gatewayStatus || "n/a"}</span
759
782
  >
760
- ${item.status}
783
+ <span class="inline-flex items-center">
784
+ <span
785
+ class=${`h-2.5 w-2.5 rounded-full ${statusTone.dotClass}`}
786
+ title=${item.status || "unknown"}
787
+ aria-label=${item.status || "unknown"}
788
+ ></span>
761
789
  </span>
762
- </div>
790
+ </span>
763
791
  </div>
764
- </button>
792
+ </summary>
765
793
  ${expandedRows.has(item.id)
766
794
  ? html`
767
- <div class="mt-2 space-y-3">
795
+ <div class="ac-history-body space-y-3">
768
796
  <div>
769
797
  <p class="text-[11px] text-gray-500 mb-1">
770
798
  Headers
@@ -852,8 +880,9 @@ ${jsonPretty(item.gatewayBody)}</pre
852
880
  </div>
853
881
  `
854
882
  : null}
855
- </div>
856
- `,
883
+ </details>
884
+ `;
885
+ },
857
886
  )}
858
887
  </div>
859
888
  `}
@@ -93,6 +93,11 @@ export async function triggerWatchdogRepair() {
93
93
  return parseJsonOrThrow(res, 'Could not trigger watchdog repair');
94
94
  }
95
95
 
96
+ export async function fetchWatchdogResources() {
97
+ const res = await authFetch('/api/watchdog/resources');
98
+ return parseJsonOrThrow(res, 'Could not load system resources');
99
+ }
100
+
96
101
  export async function fetchWatchdogSettings() {
97
102
  const res = await authFetch('/api/watchdog/settings');
98
103
  return parseJsonOrThrow(res, 'Could not load watchdog settings');
@@ -143,6 +143,8 @@ const kSystemVars = new Set([
143
143
  "OPENCLAW_GATEWAY_TOKEN",
144
144
  "SETUP_PASSWORD",
145
145
  "PORT",
146
+ "WATCHDOG_AUTO_REPAIR",
147
+ "WATCHDOG_NOTIFICATIONS_DISABLED",
146
148
  ]);
147
149
  const kKnownVars = [
148
150
  {
@@ -1,3 +1,5 @@
1
+ const { getSystemResources } = require("../system-resources");
2
+
1
3
  const registerWatchdogRoutes = ({
2
4
  app,
3
5
  requireAuth,
@@ -55,6 +57,15 @@ const registerWatchdogRoutes = ({
55
57
  }
56
58
  });
57
59
 
60
+ app.get("/api/watchdog/resources", requireAuth, (req, res) => {
61
+ try {
62
+ const status = watchdog.getStatus();
63
+ res.json({ ok: true, resources: getSystemResources({ gatewayPid: status.gatewayPid }) });
64
+ } catch (err) {
65
+ res.status(500).json({ ok: false, error: err.message });
66
+ }
67
+ });
68
+
58
69
  app.put("/api/watchdog/settings", requireAuth, (req, res) => {
59
70
  try {
60
71
  const settings = watchdog.updateSettings(req.body || {});
@@ -0,0 +1,149 @@
1
+ const os = require("os");
2
+ const fs = require("fs");
3
+ const { execSync } = require("child_process");
4
+
5
+ const readCgroupFile = (filePath) => {
6
+ try {
7
+ return fs.readFileSync(filePath, "utf8").trim();
8
+ } catch {
9
+ return null;
10
+ }
11
+ };
12
+
13
+ const parseCgroupMemory = () => {
14
+ const current = readCgroupFile("/sys/fs/cgroup/memory.current");
15
+ const max = readCgroupFile("/sys/fs/cgroup/memory.max");
16
+ if (!current) return null;
17
+ const usedBytes = Number.parseInt(current, 10);
18
+ if (Number.isNaN(usedBytes)) return null;
19
+ const limitBytes =
20
+ max && max !== "max" ? Number.parseInt(max, 10) : null;
21
+ return {
22
+ usedBytes,
23
+ totalBytes: Number.isNaN(limitBytes) ? null : limitBytes,
24
+ };
25
+ };
26
+
27
+ const parseCgroupCpu = () => {
28
+ const stat = readCgroupFile("/sys/fs/cgroup/cpu.stat");
29
+ if (!stat) return null;
30
+ const lines = stat.split("\n");
31
+ const map = {};
32
+ for (const line of lines) {
33
+ const [key, val] = line.split(/\s+/);
34
+ if (key && val) map[key] = Number.parseInt(val, 10);
35
+ }
36
+ return {
37
+ usageUsec: map.usage_usec ?? null,
38
+ userUsec: map.user_usec ?? null,
39
+ systemUsec: map.system_usec ?? null,
40
+ };
41
+ };
42
+
43
+ const readProcStatus = (pid) => {
44
+ try {
45
+ const status = fs.readFileSync(`/proc/${pid}/status`, "utf8");
46
+ const vmRss = status.match(/VmRSS:\s+(\d+)\s+kB/);
47
+ return { rssBytes: vmRss ? Number.parseInt(vmRss[1], 10) * 1024 : null };
48
+ } catch {
49
+ return null;
50
+ }
51
+ };
52
+
53
+ const readPsStats = (pid) => {
54
+ try {
55
+ const out = execSync(`ps -o rss=,pcpu= -p ${pid}`, {
56
+ encoding: "utf8",
57
+ timeout: 2000,
58
+ stdio: ["ignore", "pipe", "ignore"],
59
+ }).trim();
60
+ const [rss, pcpu] = out.split(/\s+/);
61
+ return {
62
+ rssBytes: rss ? Number.parseInt(rss, 10) * 1024 : null,
63
+ cpuPercent: pcpu ? Number.parseFloat(pcpu) : null,
64
+ };
65
+ } catch {
66
+ return null;
67
+ }
68
+ };
69
+
70
+ const getProcessUsage = (pid) => {
71
+ if (!pid) return null;
72
+ const proc = readProcStatus(pid);
73
+ if (proc) return { rssBytes: proc.rssBytes };
74
+ const ps = readPsStats(pid);
75
+ if (ps) return { rssBytes: ps.rssBytes };
76
+ return null;
77
+ };
78
+
79
+ let prevCpuSnapshot = null;
80
+ let prevCpuSnapshotAt = 0;
81
+
82
+ const getSystemResources = ({ gatewayPid = null } = {}) => {
83
+ const cgroupMem = parseCgroupMemory();
84
+ const mem = {
85
+ usedBytes: cgroupMem?.usedBytes ?? process.memoryUsage().rss,
86
+ totalBytes: cgroupMem?.totalBytes ?? os.totalmem(),
87
+ };
88
+
89
+ let diskUsedBytes = null;
90
+ let diskTotalBytes = null;
91
+ try {
92
+ const stat = fs.statfsSync("/");
93
+ diskTotalBytes = stat.bsize * stat.blocks;
94
+ diskUsedBytes = stat.bsize * (stat.blocks - stat.bfree);
95
+ } catch {
96
+ // statfsSync unavailable
97
+ }
98
+
99
+ const cgroupCpu = parseCgroupCpu();
100
+ let cpuPercent = null;
101
+ if (cgroupCpu?.usageUsec != null) {
102
+ const now = Date.now();
103
+ if (prevCpuSnapshot && prevCpuSnapshotAt) {
104
+ const elapsedMs = now - prevCpuSnapshotAt;
105
+ if (elapsedMs > 0) {
106
+ const usageDeltaUs = cgroupCpu.usageUsec - prevCpuSnapshot.usageUsec;
107
+ const elapsedUs = elapsedMs * 1000;
108
+ cpuPercent = Math.min(100, Math.max(0, (usageDeltaUs / elapsedUs) * 100));
109
+ }
110
+ }
111
+ prevCpuSnapshot = cgroupCpu;
112
+ prevCpuSnapshotAt = now;
113
+ } else {
114
+ const load = os.loadavg();
115
+ const cpus = os.cpus().length || 1;
116
+ cpuPercent = Math.min(100, Math.max(0, (load[0] / cpus) * 100));
117
+ }
118
+
119
+ const alphaclawRss = process.memoryUsage().rss;
120
+ const gatewayUsage = getProcessUsage(gatewayPid);
121
+ const gatewayRss = gatewayUsage?.rssBytes ?? null;
122
+
123
+ return {
124
+ memory: {
125
+ usedBytes: mem.usedBytes,
126
+ totalBytes: mem.totalBytes,
127
+ percent: mem.totalBytes
128
+ ? Math.round((mem.usedBytes / mem.totalBytes) * 1000) / 10
129
+ : null,
130
+ },
131
+ disk: {
132
+ usedBytes: diskUsedBytes,
133
+ totalBytes: diskTotalBytes,
134
+ percent: diskTotalBytes
135
+ ? Math.round((diskUsedBytes / diskTotalBytes) * 1000) / 10
136
+ : null,
137
+ },
138
+ cpu: {
139
+ percent: cpuPercent != null ? Math.round(cpuPercent * 10) / 10 : null,
140
+ cores: os.cpus().length,
141
+ },
142
+ processes: {
143
+ alphaclaw: { rssBytes: alphaclawRss },
144
+ gateway: { rssBytes: gatewayRss, pid: gatewayPid },
145
+ },
146
+ };
147
+ };
148
+
149
+ module.exports = { getSystemResources };
@@ -59,6 +59,7 @@ const createWatchdog = ({
59
59
  notificationsDisabled: isTruthy(process.env.WATCHDOG_NOTIFICATIONS_DISABLED),
60
60
  operationInProgress: false,
61
61
  gatewayStartedAt: null,
62
+ gatewayPid: null,
62
63
  crashRecoveryActive: false,
63
64
  expectedRestartInProgress: false,
64
65
  expectedRestartUntilMs: 0,
@@ -502,13 +503,14 @@ const createWatchdog = ({
502
503
  void restartAfterCrash(correlationId);
503
504
  };
504
505
 
505
- const onGatewayLaunch = ({ startedAt = Date.now() } = {}) => {
506
+ const onGatewayLaunch = ({ startedAt = Date.now(), pid = null } = {}) => {
506
507
  state.lifecycle = "running";
507
508
  state.health = "unknown";
508
509
  state.crashRecoveryActive = false;
509
510
  clearExpectedRestartWindow();
510
511
  state.uptimeStartedAt = startedAt;
511
512
  state.gatewayStartedAt = startedAt;
513
+ state.gatewayPid = pid;
512
514
  startBootstrapHealthChecks();
513
515
  };
514
516
 
@@ -566,6 +568,7 @@ const createWatchdog = ({
566
568
  crashLoopThreshold: kWatchdogCrashLoopThreshold,
567
569
  crashLoopWindowMs: kWatchdogCrashLoopWindowMs,
568
570
  operationInProgress: state.operationInProgress,
571
+ gatewayPid: state.gatewayPid,
569
572
  };
570
573
  };
571
574
 
package/package.json CHANGED
@@ -1,10 +1,19 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.3.0",
3
+ "version": "0.3.2-beta.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
7
  "description": "Setup UI, gateway manager, and onboarding wrapper for OpenClaw",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/chrysb/alphaclaw.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/chrysb/alphaclaw/issues"
14
+ },
15
+ "homepage": "https://github.com/chrysb/alphaclaw#readme",
16
+ "license": "MIT",
8
17
  "bin": {
9
18
  "alphaclaw": "bin/alphaclaw.js"
10
19
  },