@castlekit/castle 0.0.1 → 0.1.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/README.md +38 -1
- package/bin/castle.js +94 -0
- package/install.sh +722 -0
- package/next.config.ts +7 -0
- package/package.json +54 -5
- package/postcss.config.mjs +7 -0
- package/src/app/api/avatars/[id]/route.ts +75 -0
- package/src/app/api/openclaw/agents/route.ts +107 -0
- package/src/app/api/openclaw/config/route.ts +94 -0
- package/src/app/api/openclaw/events/route.ts +96 -0
- package/src/app/api/openclaw/logs/route.ts +59 -0
- package/src/app/api/openclaw/ping/route.ts +68 -0
- package/src/app/api/openclaw/restart/route.ts +65 -0
- package/src/app/api/openclaw/sessions/route.ts +62 -0
- package/src/app/globals.css +286 -0
- package/src/app/icon.png +0 -0
- package/src/app/layout.tsx +42 -0
- package/src/app/page.tsx +269 -0
- package/src/app/ui-kit/page.tsx +684 -0
- package/src/cli/onboarding.ts +576 -0
- package/src/components/dashboard/agent-status.tsx +107 -0
- package/src/components/dashboard/glass-card.tsx +28 -0
- package/src/components/dashboard/goal-widget.tsx +174 -0
- package/src/components/dashboard/greeting-widget.tsx +78 -0
- package/src/components/dashboard/index.ts +7 -0
- package/src/components/dashboard/stat-widget.tsx +61 -0
- package/src/components/dashboard/stock-widget.tsx +164 -0
- package/src/components/dashboard/weather-widget.tsx +68 -0
- package/src/components/icons/castle-icon.tsx +21 -0
- package/src/components/kanban/index.ts +3 -0
- package/src/components/kanban/kanban-board.tsx +391 -0
- package/src/components/kanban/kanban-card.tsx +137 -0
- package/src/components/kanban/kanban-column.tsx +98 -0
- package/src/components/layout/index.ts +4 -0
- package/src/components/layout/page-header.tsx +20 -0
- package/src/components/layout/sidebar.tsx +128 -0
- package/src/components/layout/theme-toggle.tsx +59 -0
- package/src/components/layout/user-menu.tsx +72 -0
- package/src/components/ui/alert.tsx +72 -0
- package/src/components/ui/avatar.tsx +87 -0
- package/src/components/ui/badge.tsx +39 -0
- package/src/components/ui/button.tsx +43 -0
- package/src/components/ui/card.tsx +107 -0
- package/src/components/ui/checkbox.tsx +56 -0
- package/src/components/ui/clock.tsx +171 -0
- package/src/components/ui/dialog.tsx +105 -0
- package/src/components/ui/index.ts +34 -0
- package/src/components/ui/input.tsx +112 -0
- package/src/components/ui/option-card.tsx +151 -0
- package/src/components/ui/progress.tsx +103 -0
- package/src/components/ui/radio.tsx +109 -0
- package/src/components/ui/select.tsx +46 -0
- package/src/components/ui/slider.tsx +62 -0
- package/src/components/ui/tabs.tsx +132 -0
- package/src/components/ui/toggle-group.tsx +85 -0
- package/src/components/ui/toggle.tsx +78 -0
- package/src/components/ui/tooltip.tsx +145 -0
- package/src/components/ui/uptime.tsx +106 -0
- package/src/lib/config.ts +195 -0
- package/src/lib/gateway-connection.ts +391 -0
- package/src/lib/hooks/use-openclaw.ts +163 -0
- package/src/lib/utils.ts +6 -0
- package/tsconfig.json +34 -0
package/next.config.ts
ADDED
package/package.json
CHANGED
|
@@ -1,17 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@castlekit/castle",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "The
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The multi-agent workspace",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"castle": "./bin/castle.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "next dev -p 3333",
|
|
11
|
+
"build": "next build",
|
|
12
|
+
"start": "next start -p 3333",
|
|
13
|
+
"lint": "next lint"
|
|
14
|
+
},
|
|
6
15
|
"repository": {
|
|
7
16
|
"type": "git",
|
|
8
17
|
"url": "git+https://github.com/castlekit/castle.git"
|
|
9
18
|
},
|
|
10
|
-
"keywords": [
|
|
19
|
+
"keywords": [
|
|
20
|
+
"ai",
|
|
21
|
+
"agents",
|
|
22
|
+
"openclaw",
|
|
23
|
+
"multi-agent",
|
|
24
|
+
"local-first"
|
|
25
|
+
],
|
|
11
26
|
"author": "Castle Kit Inc.",
|
|
12
27
|
"license": "MIT",
|
|
13
28
|
"bugs": {
|
|
14
29
|
"url": "https://github.com/castlekit/castle/issues"
|
|
15
30
|
},
|
|
16
|
-
"homepage": "https://
|
|
31
|
+
"homepage": "https://castlekit.com",
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=22"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@clack/prompts": "^1.0.0",
|
|
37
|
+
"@dnd-kit/core": "^6.3.1",
|
|
38
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
39
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
40
|
+
"@radix-ui/react-slider": "^1.3.6",
|
|
41
|
+
"@tailwindcss/postcss": "^4.1.18",
|
|
42
|
+
"@types/node": "^25.2.1",
|
|
43
|
+
"@types/react": "^19.2.13",
|
|
44
|
+
"@types/react-dom": "^19.2.3",
|
|
45
|
+
"clsx": "^2.1.1",
|
|
46
|
+
"commander": "^14.0.3",
|
|
47
|
+
"json5": "^2.2.3",
|
|
48
|
+
"lucide-react": "^0.563.0",
|
|
49
|
+
"next": "^16.1.6",
|
|
50
|
+
"next-themes": "^0.4.6",
|
|
51
|
+
"open": "^11.0.0",
|
|
52
|
+
"picocolors": "^1.1.1",
|
|
53
|
+
"react": "^19.2.4",
|
|
54
|
+
"react-dom": "^19.2.4",
|
|
55
|
+
"recharts": "^3.7.0",
|
|
56
|
+
"swr": "^2.4.0",
|
|
57
|
+
"tailwind-merge": "^3.4.0",
|
|
58
|
+
"tailwindcss": "^4.1.18",
|
|
59
|
+
"tsx": "^4.21.0",
|
|
60
|
+
"typescript": "^5.9.3",
|
|
61
|
+
"ws": "^8.19.0"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@types/ws": "^8.18.1"
|
|
65
|
+
}
|
|
17
66
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { readFileSync, existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
|
|
6
|
+
export const dynamic = "force-dynamic";
|
|
7
|
+
|
|
8
|
+
const AVATAR_DIRS = [
|
|
9
|
+
join(homedir(), ".castle", "avatars"),
|
|
10
|
+
join(homedir(), ".openclaw", "avatars"),
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const MIME_TYPES: Record<string, string> = {
|
|
14
|
+
".png": "image/png",
|
|
15
|
+
".jpg": "image/jpeg",
|
|
16
|
+
".jpeg": "image/jpeg",
|
|
17
|
+
".gif": "image/gif",
|
|
18
|
+
".webp": "image/webp",
|
|
19
|
+
".svg": "image/svg+xml",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* GET /api/avatars/[id]
|
|
24
|
+
* Serves avatar images from ~/.castle/avatars/ or ~/.openclaw/avatars/
|
|
25
|
+
* Supports IDs with or without extension (tries .png, .jpg, .webp)
|
|
26
|
+
*/
|
|
27
|
+
export async function GET(
|
|
28
|
+
_request: NextRequest,
|
|
29
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
30
|
+
) {
|
|
31
|
+
const { id } = await params;
|
|
32
|
+
|
|
33
|
+
// Sanitize -- prevent path traversal
|
|
34
|
+
const safeId = id.replace(/[^a-zA-Z0-9._-]/g, "");
|
|
35
|
+
if (!safeId || safeId.includes("..")) {
|
|
36
|
+
return new NextResponse("Not found", { status: 404 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Try each avatar directory
|
|
40
|
+
for (const dir of AVATAR_DIRS) {
|
|
41
|
+
if (!existsSync(dir)) continue;
|
|
42
|
+
|
|
43
|
+
// Try exact filename first (if it has an extension)
|
|
44
|
+
const hasExtension = /\.\w+$/.test(safeId);
|
|
45
|
+
if (hasExtension) {
|
|
46
|
+
const filePath = join(dir, safeId);
|
|
47
|
+
if (existsSync(filePath)) {
|
|
48
|
+
return serveFile(filePath);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Try common extensions
|
|
53
|
+
for (const ext of [".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"]) {
|
|
54
|
+
const filePath = join(dir, `${safeId}${ext}`);
|
|
55
|
+
if (existsSync(filePath)) {
|
|
56
|
+
return serveFile(filePath);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return new NextResponse("Not found", { status: 404 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function serveFile(filePath: string): NextResponse {
|
|
65
|
+
const data = readFileSync(filePath);
|
|
66
|
+
const ext = filePath.substring(filePath.lastIndexOf(".")).toLowerCase();
|
|
67
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
68
|
+
|
|
69
|
+
return new NextResponse(data, {
|
|
70
|
+
headers: {
|
|
71
|
+
"Content-Type": contentType,
|
|
72
|
+
"Cache-Control": "public, max-age=86400, immutable",
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { ensureGateway } from "@/lib/gateway-connection";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
|
|
6
|
+
interface AgentIdentity {
|
|
7
|
+
name?: string;
|
|
8
|
+
theme?: string;
|
|
9
|
+
emoji?: string;
|
|
10
|
+
avatar?: string;
|
|
11
|
+
avatarUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface GatewayAgent {
|
|
15
|
+
id: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
identity?: AgentIdentity;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface AgentsListPayload {
|
|
21
|
+
defaultId?: string;
|
|
22
|
+
mainKey?: string;
|
|
23
|
+
scope?: string;
|
|
24
|
+
agents: GatewayAgent[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rewrite avatar URLs to local /api/avatars/ endpoint.
|
|
29
|
+
* Handles URLs like "http://localhost:8787/api/v1/avatars/HASH" -> "/api/avatars/HASH"
|
|
30
|
+
*/
|
|
31
|
+
function rewriteAvatarUrl(url: string | null): string | null {
|
|
32
|
+
if (!url) return null;
|
|
33
|
+
|
|
34
|
+
// Extract hash from known avatar URL patterns
|
|
35
|
+
const patterns = [
|
|
36
|
+
/\/api\/v\d+\/avatars\/([a-f0-9]+)/i,
|
|
37
|
+
/\/avatars\/([a-f0-9]+)/i,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
for (const pattern of patterns) {
|
|
41
|
+
const match = url.match(pattern);
|
|
42
|
+
if (match) {
|
|
43
|
+
return `/api/avatars/${match[1]}`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// If it's already a relative path or data URI, pass through
|
|
48
|
+
if (url.startsWith("/") || url.startsWith("data:")) {
|
|
49
|
+
return url;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Only allow http/https URLs through; reject file:, javascript:, etc.
|
|
53
|
+
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
54
|
+
return url;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* GET /api/openclaw/agents
|
|
62
|
+
* Discover agents from OpenClaw Gateway via agents.list
|
|
63
|
+
*/
|
|
64
|
+
export async function GET() {
|
|
65
|
+
const gw = ensureGateway();
|
|
66
|
+
|
|
67
|
+
if (!gw.isConnected) {
|
|
68
|
+
return NextResponse.json(
|
|
69
|
+
{ error: "Gateway not connected", state: gw.state, agents: [] },
|
|
70
|
+
{ status: 503 }
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const result = await gw.request<AgentsListPayload>("agents.list", {});
|
|
76
|
+
|
|
77
|
+
const agents = (result?.agents || []).map((agent: GatewayAgent) => {
|
|
78
|
+
const name =
|
|
79
|
+
agent.identity?.name || agent.name || agent.id;
|
|
80
|
+
const rawAvatar =
|
|
81
|
+
agent.identity?.avatarUrl || agent.identity?.avatar || null;
|
|
82
|
+
const emoji = agent.identity?.emoji || null;
|
|
83
|
+
const description = agent.identity?.theme || null;
|
|
84
|
+
|
|
85
|
+
// Rewrite external avatar URLs to our local /api/avatars/ endpoint
|
|
86
|
+
const avatar = rewriteAvatarUrl(rawAvatar);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
id: agent.id,
|
|
90
|
+
name,
|
|
91
|
+
description,
|
|
92
|
+
avatar,
|
|
93
|
+
emoji,
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return NextResponse.json({
|
|
98
|
+
agents,
|
|
99
|
+
defaultId: result?.defaultId,
|
|
100
|
+
});
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return NextResponse.json(
|
|
103
|
+
{ error: err instanceof Error ? err.message : "Failed to list agents", agents: [] },
|
|
104
|
+
{ status: 500 }
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import JSON5 from "json5";
|
|
5
|
+
import { getOpenClawDir } from "@/lib/config";
|
|
6
|
+
import { ensureGateway } from "@/lib/gateway-connection";
|
|
7
|
+
|
|
8
|
+
export const dynamic = "force-dynamic";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GET /api/openclaw/config
|
|
12
|
+
* God mode: reads OpenClaw config from filesystem
|
|
13
|
+
*/
|
|
14
|
+
export async function GET() {
|
|
15
|
+
const configPath = join(getOpenClawDir(), "openclaw.json");
|
|
16
|
+
|
|
17
|
+
if (!existsSync(configPath)) {
|
|
18
|
+
// Try json5
|
|
19
|
+
const json5Path = join(getOpenClawDir(), "openclaw.json5");
|
|
20
|
+
if (!existsSync(json5Path)) {
|
|
21
|
+
return NextResponse.json(
|
|
22
|
+
{ error: "OpenClaw config not found" },
|
|
23
|
+
{ status: 404 }
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const raw = readFileSync(json5Path, "utf-8");
|
|
29
|
+
const config = JSON5.parse(raw);
|
|
30
|
+
return NextResponse.json({ config, format: "json5" });
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return NextResponse.json(
|
|
33
|
+
{ error: err instanceof Error ? err.message : "Failed to parse config" },
|
|
34
|
+
{ status: 500 }
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
41
|
+
const config = JSON5.parse(raw);
|
|
42
|
+
return NextResponse.json({ config, format: "json" });
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return NextResponse.json(
|
|
45
|
+
{ error: err instanceof Error ? err.message : "Failed to parse config" },
|
|
46
|
+
{ status: 500 }
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* PATCH /api/openclaw/config
|
|
53
|
+
* Update OpenClaw config via Gateway's config.patch method.
|
|
54
|
+
* Body: { patch: { ... } } -- the patch to apply
|
|
55
|
+
*/
|
|
56
|
+
export async function PATCH(request: NextRequest) {
|
|
57
|
+
const gw = ensureGateway();
|
|
58
|
+
|
|
59
|
+
if (!gw.isConnected) {
|
|
60
|
+
return NextResponse.json(
|
|
61
|
+
{ error: "Gateway not connected" },
|
|
62
|
+
{ status: 503 }
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
let body: unknown;
|
|
68
|
+
try {
|
|
69
|
+
body = await request.json();
|
|
70
|
+
} catch {
|
|
71
|
+
return NextResponse.json(
|
|
72
|
+
{ error: "Invalid JSON in request body" },
|
|
73
|
+
{ status: 400 }
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { patch } = body as { patch?: unknown };
|
|
78
|
+
|
|
79
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
|
80
|
+
return NextResponse.json(
|
|
81
|
+
{ error: "Missing or invalid 'patch' field — must be a JSON object" },
|
|
82
|
+
{ status: 400 }
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await gw.request("config.patch", patch);
|
|
87
|
+
return NextResponse.json({ ok: true });
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return NextResponse.json(
|
|
90
|
+
{ error: err instanceof Error ? err.message : "Config patch failed" },
|
|
91
|
+
{ status: 500 }
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { ensureGateway, type GatewayEvent } from "@/lib/gateway-connection";
|
|
2
|
+
|
|
3
|
+
export const dynamic = "force-dynamic";
|
|
4
|
+
export const runtime = "nodejs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* GET /api/openclaw/events
|
|
8
|
+
* SSE endpoint -- streams OpenClaw Gateway events to the browser in real-time.
|
|
9
|
+
* Browser connects once via EventSource and receives push updates.
|
|
10
|
+
*/
|
|
11
|
+
export async function GET() {
|
|
12
|
+
const gw = ensureGateway();
|
|
13
|
+
|
|
14
|
+
const encoder = new TextEncoder();
|
|
15
|
+
let closed = false;
|
|
16
|
+
|
|
17
|
+
const stream = new ReadableStream({
|
|
18
|
+
start(controller) {
|
|
19
|
+
// Send initial state
|
|
20
|
+
const initial = {
|
|
21
|
+
event: "castle.state",
|
|
22
|
+
payload: {
|
|
23
|
+
state: gw.state,
|
|
24
|
+
isConnected: gw.isConnected,
|
|
25
|
+
server: gw.serverInfo,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(initial)}\n\n`));
|
|
29
|
+
|
|
30
|
+
// Forward gateway events
|
|
31
|
+
const onGatewayEvent = (evt: GatewayEvent) => {
|
|
32
|
+
if (closed) return;
|
|
33
|
+
try {
|
|
34
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\n\n`));
|
|
35
|
+
} catch {
|
|
36
|
+
// Stream may have closed
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Forward state changes
|
|
41
|
+
const onStateChange = (state: string) => {
|
|
42
|
+
if (closed) return;
|
|
43
|
+
try {
|
|
44
|
+
const msg = {
|
|
45
|
+
event: "castle.state",
|
|
46
|
+
payload: {
|
|
47
|
+
state,
|
|
48
|
+
isConnected: gw.isConnected,
|
|
49
|
+
server: gw.serverInfo,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(msg)}\n\n`));
|
|
53
|
+
} catch {
|
|
54
|
+
// Stream may have closed
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Heartbeat to keep connection alive
|
|
59
|
+
const heartbeat = setInterval(() => {
|
|
60
|
+
if (closed) return;
|
|
61
|
+
try {
|
|
62
|
+
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
|
|
63
|
+
} catch {
|
|
64
|
+
// Stream may have closed
|
|
65
|
+
}
|
|
66
|
+
}, 30000);
|
|
67
|
+
|
|
68
|
+
gw.on("gatewayEvent", onGatewayEvent);
|
|
69
|
+
gw.on("stateChange", onStateChange);
|
|
70
|
+
|
|
71
|
+
// Cleanup when the client disconnects
|
|
72
|
+
const cleanup = () => {
|
|
73
|
+
closed = true;
|
|
74
|
+
clearInterval(heartbeat);
|
|
75
|
+
gw.off("gatewayEvent", onGatewayEvent);
|
|
76
|
+
gw.off("stateChange", onStateChange);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// The stream's cancel is called when the client disconnects
|
|
80
|
+
// We store cleanup for the cancel callback
|
|
81
|
+
(controller as unknown as { _cleanup: () => void })._cleanup = cleanup;
|
|
82
|
+
},
|
|
83
|
+
cancel(controller) {
|
|
84
|
+
const cleanup = (controller as unknown as { _cleanup: () => void })._cleanup;
|
|
85
|
+
if (cleanup) cleanup();
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return new Response(stream, {
|
|
90
|
+
headers: {
|
|
91
|
+
"Content-Type": "text/event-stream",
|
|
92
|
+
"Cache-Control": "no-cache, no-transform",
|
|
93
|
+
Connection: "keep-alive",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { readFileSync, existsSync, readdirSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { getOpenClawDir } from "@/lib/config";
|
|
5
|
+
|
|
6
|
+
export const dynamic = "force-dynamic";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* GET /api/openclaw/logs?lines=100&file=gateway
|
|
10
|
+
* God mode: reads log files from ~/.openclaw/logs/
|
|
11
|
+
*/
|
|
12
|
+
export async function GET(request: NextRequest) {
|
|
13
|
+
const { searchParams } = new URL(request.url);
|
|
14
|
+
const rawLines = parseInt(searchParams.get("lines") || "100", 10);
|
|
15
|
+
const lines = Math.min(Math.max(1, Number.isFinite(rawLines) ? rawLines : 100), 10000);
|
|
16
|
+
const file = searchParams.get("file")?.trim() || "gateway";
|
|
17
|
+
|
|
18
|
+
const logsDir = join(getOpenClawDir(), "logs");
|
|
19
|
+
|
|
20
|
+
if (!existsSync(logsDir)) {
|
|
21
|
+
return NextResponse.json({ logs: [], error: "No logs directory found" });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// List available log files
|
|
25
|
+
const availableFiles = existsSync(logsDir)
|
|
26
|
+
? readdirSync(logsDir).filter((f) => f.endsWith(".log"))
|
|
27
|
+
: [];
|
|
28
|
+
|
|
29
|
+
// Find matching log file
|
|
30
|
+
const logFile = availableFiles.find((f) => f.startsWith(file));
|
|
31
|
+
if (!logFile) {
|
|
32
|
+
return NextResponse.json({
|
|
33
|
+
logs: [],
|
|
34
|
+
available: availableFiles,
|
|
35
|
+
error: `Log file '${file}' not found`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const logPath = join(logsDir, logFile);
|
|
41
|
+
const content = readFileSync(logPath, "utf-8");
|
|
42
|
+
const allLines = content.split("\n").filter(Boolean);
|
|
43
|
+
|
|
44
|
+
// Return last N lines
|
|
45
|
+
const tailLines = allLines.slice(-lines);
|
|
46
|
+
|
|
47
|
+
return NextResponse.json({
|
|
48
|
+
logs: tailLines,
|
|
49
|
+
file: logFile,
|
|
50
|
+
totalLines: allLines.length,
|
|
51
|
+
available: availableFiles,
|
|
52
|
+
});
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return NextResponse.json(
|
|
55
|
+
{ error: err instanceof Error ? err.message : "Failed to read logs" },
|
|
56
|
+
{ status: 500 }
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { ensureGateway } from "@/lib/gateway-connection";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* POST /api/openclaw/ping
|
|
8
|
+
* Health check -- tests connection to OpenClaw Gateway
|
|
9
|
+
*/
|
|
10
|
+
export async function POST() {
|
|
11
|
+
const gw = ensureGateway();
|
|
12
|
+
|
|
13
|
+
if (!gw.isConfigured) {
|
|
14
|
+
return NextResponse.json({
|
|
15
|
+
ok: false,
|
|
16
|
+
configured: false,
|
|
17
|
+
error: "No OpenClaw token found. Run 'castle setup' or check ~/.openclaw/openclaw.json",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// If not connected yet, give it a moment to complete handshake
|
|
22
|
+
if (gw.state === "connecting") {
|
|
23
|
+
await new Promise<void>((resolve) => {
|
|
24
|
+
const timeout = setTimeout(resolve, 5000);
|
|
25
|
+
const onState = () => {
|
|
26
|
+
clearTimeout(timeout);
|
|
27
|
+
gw.off("stateChange", onState);
|
|
28
|
+
resolve();
|
|
29
|
+
};
|
|
30
|
+
gw.on("stateChange", onState);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!gw.isConnected) {
|
|
35
|
+
return NextResponse.json({
|
|
36
|
+
ok: false,
|
|
37
|
+
configured: true,
|
|
38
|
+
state: gw.state,
|
|
39
|
+
error: gw.state === "error"
|
|
40
|
+
? "Failed to connect to OpenClaw Gateway. Is it running?"
|
|
41
|
+
: "Connecting to OpenClaw Gateway...",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const start = Date.now();
|
|
47
|
+
await gw.request("health", {});
|
|
48
|
+
const latency = Date.now() - start;
|
|
49
|
+
|
|
50
|
+
return NextResponse.json({
|
|
51
|
+
ok: true,
|
|
52
|
+
configured: true,
|
|
53
|
+
latency_ms: latency,
|
|
54
|
+
server: gw.serverInfo,
|
|
55
|
+
});
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return NextResponse.json({
|
|
58
|
+
ok: false,
|
|
59
|
+
configured: true,
|
|
60
|
+
error: err instanceof Error ? err.message : "Health check failed",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Allow GET for easy status checks
|
|
66
|
+
export async function GET() {
|
|
67
|
+
return POST();
|
|
68
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* POST /api/openclaw/restart
|
|
8
|
+
* God mode: restart the OpenClaw Gateway process.
|
|
9
|
+
* Tries common methods to restart the gateway.
|
|
10
|
+
*/
|
|
11
|
+
export async function POST() {
|
|
12
|
+
try {
|
|
13
|
+
// Try openclaw CLI restart first
|
|
14
|
+
try {
|
|
15
|
+
execSync("openclaw gateway restart", {
|
|
16
|
+
timeout: 10000,
|
|
17
|
+
stdio: "pipe",
|
|
18
|
+
});
|
|
19
|
+
return NextResponse.json({
|
|
20
|
+
ok: true,
|
|
21
|
+
method: "openclaw gateway restart",
|
|
22
|
+
});
|
|
23
|
+
} catch {
|
|
24
|
+
// openclaw CLI not available or restart command failed
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Try stopping and starting via process signal
|
|
28
|
+
try {
|
|
29
|
+
const pids = execSync("pgrep -f 'openclaw.*gateway'", {
|
|
30
|
+
timeout: 5000,
|
|
31
|
+
stdio: "pipe",
|
|
32
|
+
})
|
|
33
|
+
.toString()
|
|
34
|
+
.trim()
|
|
35
|
+
.split(/\s+/)
|
|
36
|
+
.filter((p) => /^\d+$/.test(p));
|
|
37
|
+
|
|
38
|
+
const pid = pids[0];
|
|
39
|
+
if (pid) {
|
|
40
|
+
execSync(`kill -HUP ${pid}`, { timeout: 5000, stdio: "pipe" });
|
|
41
|
+
return NextResponse.json({
|
|
42
|
+
ok: true,
|
|
43
|
+
method: "SIGHUP",
|
|
44
|
+
pid: parseInt(pid, 10),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// pgrep not available or no process found
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return NextResponse.json(
|
|
52
|
+
{
|
|
53
|
+
ok: false,
|
|
54
|
+
error:
|
|
55
|
+
"Could not restart gateway. Try manually: openclaw gateway restart",
|
|
56
|
+
},
|
|
57
|
+
{ status: 500 }
|
|
58
|
+
);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return NextResponse.json(
|
|
61
|
+
{ ok: false, error: err instanceof Error ? err.message : "Restart failed" },
|
|
62
|
+
{ status: 500 }
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|