@canivel/ralph 0.2.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/.agents/ralph/PROMPT_build.md +126 -0
- package/.agents/ralph/agents.sh +15 -0
- package/.agents/ralph/config.sh +25 -0
- package/.agents/ralph/log-activity.sh +15 -0
- package/.agents/ralph/loop.sh +1001 -0
- package/.agents/ralph/references/CONTEXT_ENGINEERING.md +126 -0
- package/.agents/ralph/references/GUARDRAILS.md +174 -0
- package/AGENTS.md +20 -0
- package/README.md +266 -0
- package/bin/ralph +766 -0
- package/diagram.svg +55 -0
- package/examples/commands.md +46 -0
- package/package.json +39 -0
- package/ralph.webp +0 -0
- package/skills/commit/SKILL.md +219 -0
- package/skills/commit/references/commit_examples.md +292 -0
- package/skills/dev-browser/SKILL.md +211 -0
- package/skills/dev-browser/bun.lock +443 -0
- package/skills/dev-browser/package-lock.json +2988 -0
- package/skills/dev-browser/package.json +31 -0
- package/skills/dev-browser/references/scraping.md +155 -0
- package/skills/dev-browser/scripts/start-relay.ts +32 -0
- package/skills/dev-browser/scripts/start-server.ts +117 -0
- package/skills/dev-browser/server.sh +24 -0
- package/skills/dev-browser/src/client.ts +474 -0
- package/skills/dev-browser/src/index.ts +287 -0
- package/skills/dev-browser/src/relay.ts +731 -0
- package/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts +223 -0
- package/skills/dev-browser/src/snapshot/browser-script.ts +877 -0
- package/skills/dev-browser/src/snapshot/index.ts +14 -0
- package/skills/dev-browser/src/snapshot/inject.ts +13 -0
- package/skills/dev-browser/src/types.ts +34 -0
- package/skills/dev-browser/tsconfig.json +36 -0
- package/skills/dev-browser/vitest.config.ts +12 -0
- package/skills/prd/SKILL.md +235 -0
- package/tests/agent-loops.mjs +79 -0
- package/tests/agent-ping.mjs +39 -0
- package/tests/audit.md +56 -0
- package/tests/cli-smoke.mjs +47 -0
- package/tests/real-agents.mjs +127 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import express, { type Express, type Request, type Response } from "express";
|
|
2
|
+
import { chromium, type BrowserContext, type Page } from "playwright";
|
|
3
|
+
import { mkdirSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import type { Socket } from "net";
|
|
6
|
+
import type {
|
|
7
|
+
ServeOptions,
|
|
8
|
+
GetPageRequest,
|
|
9
|
+
GetPageResponse,
|
|
10
|
+
ListPagesResponse,
|
|
11
|
+
ServerInfoResponse,
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
export type { ServeOptions, GetPageResponse, ListPagesResponse, ServerInfoResponse };
|
|
15
|
+
|
|
16
|
+
export interface DevBrowserServer {
|
|
17
|
+
wsEndpoint: string;
|
|
18
|
+
port: number;
|
|
19
|
+
stop: () => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Helper to retry fetch with exponential backoff
|
|
23
|
+
async function fetchWithRetry(
|
|
24
|
+
url: string,
|
|
25
|
+
maxRetries = 5,
|
|
26
|
+
delayMs = 500
|
|
27
|
+
): Promise<globalThis.Response> {
|
|
28
|
+
let lastError: Error | null = null;
|
|
29
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(url);
|
|
32
|
+
if (res.ok) return res;
|
|
33
|
+
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
36
|
+
if (i < maxRetries - 1) {
|
|
37
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs * (i + 1)));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Failed after ${maxRetries} retries: ${lastError?.message}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Helper to add timeout to promises
|
|
45
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
46
|
+
return Promise.race([
|
|
47
|
+
promise,
|
|
48
|
+
new Promise<never>((_, reject) =>
|
|
49
|
+
setTimeout(() => reject(new Error(`Timeout: ${message}`)), ms)
|
|
50
|
+
),
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function serve(options: ServeOptions = {}): Promise<DevBrowserServer> {
|
|
55
|
+
const port = options.port ?? 9222;
|
|
56
|
+
const headless = options.headless ?? false;
|
|
57
|
+
const cdpPort = options.cdpPort ?? 9223;
|
|
58
|
+
const profileDir = options.profileDir;
|
|
59
|
+
|
|
60
|
+
// Validate port numbers
|
|
61
|
+
if (port < 1 || port > 65535) {
|
|
62
|
+
throw new Error(`Invalid port: ${port}. Must be between 1 and 65535`);
|
|
63
|
+
}
|
|
64
|
+
if (cdpPort < 1 || cdpPort > 65535) {
|
|
65
|
+
throw new Error(`Invalid cdpPort: ${cdpPort}. Must be between 1 and 65535`);
|
|
66
|
+
}
|
|
67
|
+
if (port === cdpPort) {
|
|
68
|
+
throw new Error("port and cdpPort must be different");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Determine user data directory for persistent context
|
|
72
|
+
const userDataDir = profileDir
|
|
73
|
+
? join(profileDir, "browser-data")
|
|
74
|
+
: join(process.cwd(), ".browser-data");
|
|
75
|
+
|
|
76
|
+
// Create directory if it doesn't exist
|
|
77
|
+
mkdirSync(userDataDir, { recursive: true });
|
|
78
|
+
console.log(`Using persistent browser profile: ${userDataDir}`);
|
|
79
|
+
|
|
80
|
+
console.log("Launching browser with persistent context...");
|
|
81
|
+
|
|
82
|
+
// Launch persistent context - this persists cookies, localStorage, cache, etc.
|
|
83
|
+
const context: BrowserContext = await chromium.launchPersistentContext(userDataDir, {
|
|
84
|
+
headless,
|
|
85
|
+
args: [`--remote-debugging-port=${cdpPort}`],
|
|
86
|
+
});
|
|
87
|
+
console.log("Browser launched with persistent profile...");
|
|
88
|
+
|
|
89
|
+
// Get the CDP WebSocket endpoint from Chrome's JSON API (with retry for slow startup)
|
|
90
|
+
const cdpResponse = await fetchWithRetry(`http://127.0.0.1:${cdpPort}/json/version`);
|
|
91
|
+
const cdpInfo = (await cdpResponse.json()) as { webSocketDebuggerUrl: string };
|
|
92
|
+
const wsEndpoint = cdpInfo.webSocketDebuggerUrl;
|
|
93
|
+
console.log(`CDP WebSocket endpoint: ${wsEndpoint}`);
|
|
94
|
+
|
|
95
|
+
// Registry entry type for page tracking
|
|
96
|
+
interface PageEntry {
|
|
97
|
+
page: Page;
|
|
98
|
+
targetId: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Registry: name -> PageEntry
|
|
102
|
+
const registry = new Map<string, PageEntry>();
|
|
103
|
+
|
|
104
|
+
// Helper to get CDP targetId for a page
|
|
105
|
+
async function getTargetId(page: Page): Promise<string> {
|
|
106
|
+
const cdpSession = await context.newCDPSession(page);
|
|
107
|
+
try {
|
|
108
|
+
const { targetInfo } = await cdpSession.send("Target.getTargetInfo");
|
|
109
|
+
return targetInfo.targetId;
|
|
110
|
+
} finally {
|
|
111
|
+
await cdpSession.detach();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Express server for page management
|
|
116
|
+
const app: Express = express();
|
|
117
|
+
app.use(express.json());
|
|
118
|
+
|
|
119
|
+
// GET / - server info
|
|
120
|
+
app.get("/", (_req: Request, res: Response) => {
|
|
121
|
+
const response: ServerInfoResponse = { wsEndpoint };
|
|
122
|
+
res.json(response);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// GET /pages - list all pages
|
|
126
|
+
app.get("/pages", (_req: Request, res: Response) => {
|
|
127
|
+
const response: ListPagesResponse = {
|
|
128
|
+
pages: Array.from(registry.keys()),
|
|
129
|
+
};
|
|
130
|
+
res.json(response);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// POST /pages - get or create page
|
|
134
|
+
app.post("/pages", async (req: Request, res: Response) => {
|
|
135
|
+
const body = req.body as GetPageRequest;
|
|
136
|
+
const { name, viewport } = body;
|
|
137
|
+
|
|
138
|
+
if (!name || typeof name !== "string") {
|
|
139
|
+
res.status(400).json({ error: "name is required and must be a string" });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (name.length === 0) {
|
|
144
|
+
res.status(400).json({ error: "name cannot be empty" });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (name.length > 256) {
|
|
149
|
+
res.status(400).json({ error: "name must be 256 characters or less" });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check if page already exists
|
|
154
|
+
let entry = registry.get(name);
|
|
155
|
+
if (!entry) {
|
|
156
|
+
// Create new page in the persistent context (with timeout to prevent hangs)
|
|
157
|
+
const page = await withTimeout(context.newPage(), 30000, "Page creation timed out after 30s");
|
|
158
|
+
|
|
159
|
+
// Apply viewport if provided
|
|
160
|
+
if (viewport) {
|
|
161
|
+
await page.setViewportSize(viewport);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const targetId = await getTargetId(page);
|
|
165
|
+
entry = { page, targetId };
|
|
166
|
+
registry.set(name, entry);
|
|
167
|
+
|
|
168
|
+
// Clean up registry when page is closed (e.g., user clicks X)
|
|
169
|
+
page.on("close", () => {
|
|
170
|
+
registry.delete(name);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const response: GetPageResponse = { wsEndpoint, name, targetId: entry.targetId };
|
|
175
|
+
res.json(response);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// DELETE /pages/:name - close a page
|
|
179
|
+
app.delete("/pages/:name", async (req: Request<{ name: string }>, res: Response) => {
|
|
180
|
+
const name = decodeURIComponent(req.params.name);
|
|
181
|
+
const entry = registry.get(name);
|
|
182
|
+
|
|
183
|
+
if (entry) {
|
|
184
|
+
await entry.page.close();
|
|
185
|
+
registry.delete(name);
|
|
186
|
+
res.json({ success: true });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
res.status(404).json({ error: "page not found" });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Start the server
|
|
194
|
+
const server = app.listen(port, () => {
|
|
195
|
+
console.log(`HTTP API server running on port ${port}`);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Track active connections for clean shutdown
|
|
199
|
+
const connections = new Set<Socket>();
|
|
200
|
+
server.on("connection", (socket: Socket) => {
|
|
201
|
+
connections.add(socket);
|
|
202
|
+
socket.on("close", () => connections.delete(socket));
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Track if cleanup has been called to avoid double cleanup
|
|
206
|
+
let cleaningUp = false;
|
|
207
|
+
|
|
208
|
+
// Cleanup function
|
|
209
|
+
const cleanup = async () => {
|
|
210
|
+
if (cleaningUp) return;
|
|
211
|
+
cleaningUp = true;
|
|
212
|
+
|
|
213
|
+
console.log("\nShutting down...");
|
|
214
|
+
|
|
215
|
+
// Close all active HTTP connections
|
|
216
|
+
for (const socket of connections) {
|
|
217
|
+
socket.destroy();
|
|
218
|
+
}
|
|
219
|
+
connections.clear();
|
|
220
|
+
|
|
221
|
+
// Close all pages
|
|
222
|
+
for (const entry of registry.values()) {
|
|
223
|
+
try {
|
|
224
|
+
await entry.page.close();
|
|
225
|
+
} catch {
|
|
226
|
+
// Page might already be closed
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
registry.clear();
|
|
230
|
+
|
|
231
|
+
// Close context (this also closes the browser)
|
|
232
|
+
try {
|
|
233
|
+
await context.close();
|
|
234
|
+
} catch {
|
|
235
|
+
// Context might already be closed
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
server.close();
|
|
239
|
+
console.log("Server stopped.");
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Synchronous cleanup for forced exits
|
|
243
|
+
const syncCleanup = () => {
|
|
244
|
+
try {
|
|
245
|
+
context.close();
|
|
246
|
+
} catch {
|
|
247
|
+
// Best effort
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Signal handlers (consolidated to reduce duplication)
|
|
252
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
|
|
253
|
+
|
|
254
|
+
const signalHandler = async () => {
|
|
255
|
+
await cleanup();
|
|
256
|
+
process.exit(0);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const errorHandler = async (err: unknown) => {
|
|
260
|
+
console.error("Unhandled error:", err);
|
|
261
|
+
await cleanup();
|
|
262
|
+
process.exit(1);
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// Register handlers
|
|
266
|
+
signals.forEach((sig) => process.on(sig, signalHandler));
|
|
267
|
+
process.on("uncaughtException", errorHandler);
|
|
268
|
+
process.on("unhandledRejection", errorHandler);
|
|
269
|
+
process.on("exit", syncCleanup);
|
|
270
|
+
|
|
271
|
+
// Helper to remove all handlers
|
|
272
|
+
const removeHandlers = () => {
|
|
273
|
+
signals.forEach((sig) => process.off(sig, signalHandler));
|
|
274
|
+
process.off("uncaughtException", errorHandler);
|
|
275
|
+
process.off("unhandledRejection", errorHandler);
|
|
276
|
+
process.off("exit", syncCleanup);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
wsEndpoint,
|
|
281
|
+
port,
|
|
282
|
+
async stop() {
|
|
283
|
+
removeHandlers();
|
|
284
|
+
await cleanup();
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|