Skip to content

Commit 887e6d7

Browse files
committed
feat: add daemon mode, URL routing, and bounded dedup to debug server
Self-daemonizing --daemon flag eliminates backgrounding issues across agents. URL route validation at /ingest/:sessionId enables multi-session support and 404s for malformed paths. Bounded dedup set caps at 10k entries. SKILL.md updated to use --daemon and DELETE endpoint.
1 parent 981f9e1 commit 887e6d7

6 files changed

Lines changed: 927 additions & 131 deletions

File tree

packages/debug-agent/skill/SKILL.md

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,13 @@ They guess based on code alone. You **cannot** and **must NOT** fix bugs this wa
4242

4343
### STEP 0: Start the logging server (MANDATORY BEFORE ANY INSTRUMENTATION)
4444

45-
**CRITICAL: The server is a long-running process. You MUST run it in the BACKGROUND.**
46-
47-
Run the debug server as a **background process** before any instrumentation. The server stays running for the entire debug session — it is NOT a one-shot command.
45+
Run the debug server in **daemon mode** before any instrumentation. The `--daemon` flag starts the server in the background and exits immediately with the server info — no backgrounding or `&` required.
4846

4947
```bash
50-
npx debug-agent 2>&1 &
48+
npx debug-agent --daemon
5149
```
5250

53-
**YOU MUST BACKGROUND THIS COMMAND.** Do NOT run it in the foreground. Do NOT wait for it to complete — it never completes, it is a persistent server. Use `&` (shell background), `nohup`, or your agent's background/async command execution. If your agent platform supports `block_until_ms: 0` or equivalent, use that. If it supports running commands in a separate terminal, do that. **The command MUST NOT block your workflow.**
54-
55-
The server prints a single JSON line to stdout on startup:
51+
The command prints a single JSON line to stdout and exits:
5652

5753
```json
5854
{
@@ -129,12 +125,10 @@ fetch('ENDPOINT',{method:'POST',headers:{'Content-Type':'application/json'},body
129125

130126
### STEP 3: Clear previous log file before each run (MANDATORY)
131127

132-
- Use the delete_file tool to delete the file at the **log path** before asking the user to run.
133-
- If delete_file unavailable or fails: instruct user to manually delete the log file.
128+
- Send a `DELETE` request to the **server endpoint** to clear the log file before each run. For example: `curl -X DELETE ENDPOINT` (replace `ENDPOINT` with the endpoint value from Step 0).
134129
- This ensures clean logs for the new run without mixing old and new data.
135-
- Do NOT use shell commands (rm, touch, etc.); use the delete_file tool only.
136130
- Clearing the log file is NOT the same as removing instrumentation; do not remove any debug logs from code here.
137-
- **CRITICAL:** Only delete YOUR log file (the one at the log path from Step 0). NEVER delete, modify, or overwrite log files belonging to other debug sessions. Other sessions may have log files in the same directory with different session IDs in their filenames — leave them untouched.
131+
- **CRITICAL:** Only clear YOUR session's logs (via your endpoint from Step 0). NEVER delete, modify, or overwrite log files belonging to other debug sessions.
138132

139133
### STEP 4: Read logs after user runs the program
140134

@@ -159,7 +153,7 @@ fetch('ENDPOINT',{method:'POST',headers:{'Content-Type':'application/json'},body
159153
- FORBIDDEN: Using `setTimeout`, `sleep`, or artificial delays as a "fix"; use proper reactivity/events/lifecycles.
160154
- FORBIDDEN: Removing instrumentation before analyzing post-fix verification logs or receiving explicit user confirmation.
161155
- Verification requires before/after log comparison with cited log lines; do not claim success without log proof.
162-
- Clear logs using the delete_file tool only (never shell commands like rm, touch, etc.).
156+
- Clear logs by sending a DELETE request to the server endpoint.
163157
- Do not create the log file manually; it's created automatically.
164158
- Clearing the log file is not removing instrumentation.
165159
- NEVER delete or modify log files that do not belong to this session. Only touch the log file at the exact path from Step 0.
Lines changed: 103 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Command } from "commander";
2+
import { spawn } from "node:child_process";
23
import { createServer } from "../server.js";
34
import { logger } from "../utils/logger.js";
45
import { highlighter } from "../utils/highlighter.js";
@@ -13,31 +14,113 @@ export const serveCommand = new Command("serve")
1314
"-l, --log-path <path>",
1415
"log file path (default: <tmpdir>/debug-agent/debug-<sessionId>.log)",
1516
)
17+
.option("-d, --daemon", "start server in background and exit")
18+
.option("--json", "output server info as JSON (no spinner/colors)")
1619
.action(async (options) => {
17-
const startSpinner = spinner("Starting debug-agent server...").start();
18-
19-
const { server, info } = await createServer({
20-
port: options.port,
21-
host: options.host,
22-
sessionId: options.sessionId,
23-
logPath: options.logPath,
24-
});
20+
if (options.daemon) {
21+
await startDaemon(options);
22+
return;
23+
}
2524

26-
if (!server) {
27-
startSpinner.succeed(`Server already running on port ${highlighter.bold(String(info.port))}`);
28-
logger.dim(` ${info.endpoint}`);
25+
if (options.json) {
26+
await startJson(options);
2927
return;
3028
}
3129

32-
startSpinner.succeed(`Server listening on port ${highlighter.bold(String(info.port))}`);
33-
logger.dim(` Endpoint: ${info.endpoint}`);
34-
logger.dim(` Log path: ${info.logPath}`);
30+
await startInteractive(options);
31+
});
32+
33+
interface ServeOptions {
34+
port?: number;
35+
host: string;
36+
sessionId?: string;
37+
logPath?: string;
38+
}
39+
40+
const startDaemon = async (options: ServeOptions) => {
41+
const childArgs = [process.argv[1], "serve", "--json"];
42+
if (options.port) childArgs.push("-p", String(options.port));
43+
if (options.host !== "127.0.0.1") childArgs.push("-H", options.host);
44+
if (options.sessionId) childArgs.push("-s", options.sessionId);
45+
if (options.logPath) childArgs.push("-l", options.logPath);
46+
47+
const childProcess = spawn(process.execPath, childArgs, {
48+
detached: true,
49+
stdio: ["ignore", "pipe", "ignore"],
50+
});
51+
52+
if (!childProcess.stdout) {
53+
logger.error("Failed to start daemon");
54+
process.exit(1);
55+
}
56+
57+
let stdoutBuffer = "";
58+
const serverInfoLine = await new Promise<string>((resolve, reject) => {
59+
childProcess.stdout!.on("data", (chunk: Buffer) => {
60+
stdoutBuffer += chunk.toString();
61+
const newlineIndex = stdoutBuffer.indexOf("\n");
62+
if (newlineIndex !== -1) {
63+
resolve(stdoutBuffer.slice(0, newlineIndex));
64+
}
65+
});
66+
childProcess.on("error", reject);
67+
childProcess.on("exit", (code) => {
68+
if (code !== 0) reject(new Error(`Server process exited with code ${code}`));
69+
});
70+
});
71+
72+
console.log(serverInfoLine);
73+
childProcess.unref();
74+
process.exit(0);
75+
};
76+
77+
const startJson = async (options: ServeOptions) => {
78+
const { server, info } = await createServer({
79+
port: options.port,
80+
host: options.host,
81+
sessionId: options.sessionId,
82+
logPath: options.logPath,
83+
});
84+
85+
console.log(JSON.stringify(info));
3586

36-
const shutdown = () => {
37-
server.close();
38-
process.exit(0);
39-
};
87+
if (!server) {
88+
process.exit(0);
89+
}
4090

41-
process.on("SIGINT", shutdown);
42-
process.on("SIGTERM", shutdown);
91+
const shutdown = () => {
92+
server.close();
93+
process.exit(0);
94+
};
95+
process.on("SIGINT", shutdown);
96+
process.on("SIGTERM", shutdown);
97+
};
98+
99+
const startInteractive = async (options: ServeOptions) => {
100+
const startSpinner = spinner("Starting debug-agent server...").start();
101+
102+
const { server, info } = await createServer({
103+
port: options.port,
104+
host: options.host,
105+
sessionId: options.sessionId,
106+
logPath: options.logPath,
43107
});
108+
109+
if (!server) {
110+
startSpinner.succeed(`Server already running on port ${highlighter.bold(String(info.port))}`);
111+
logger.dim(` ${info.endpoint}`);
112+
return;
113+
}
114+
115+
startSpinner.succeed(`Server listening on port ${highlighter.bold(String(info.port))}`);
116+
logger.dim(` Endpoint: ${info.endpoint}`);
117+
logger.dim(` Log path: ${info.logPath}`);
118+
119+
const shutdown = () => {
120+
server.close();
121+
process.exit(0);
122+
};
123+
124+
process.on("SIGINT", shutdown);
125+
process.on("SIGTERM", shutdown);
126+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export const SESSION_ID_BYTE_LENGTH = 3;
22
export const LOCK_PING_TIMEOUT_MS = 1000;
33
export const LOG_DIRECTORY_NAME = "debug-agent";
4+
export const MAX_DEDUP_ENTRIES = 10_000;
45
export const VERSION_API_URL = "https://www.debug-agent.com/api/version";

packages/debug-agent/src/server.ts

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import fs from "node:fs";
33
import os from "node:os";
44
import path from "node:path";
55
import crypto from "node:crypto";
6-
import { SESSION_ID_BYTE_LENGTH, LOG_DIRECTORY_NAME } from "./constants.js";
6+
import { SESSION_ID_BYTE_LENGTH, LOG_DIRECTORY_NAME, MAX_DEDUP_ENTRIES } from "./constants.js";
77
import { getErrorMessage } from "./utils/get-error-message.js";
88
import { readServerLock, writeServerLock, removeServerLock } from "./utils/server-lock.js";
99
import { pingServer } from "./utils/ping-server.js";
@@ -29,10 +29,25 @@ export interface ServerResult {
2929
reused: boolean;
3030
}
3131

32+
interface SessionState {
33+
logPath: string;
34+
processedEntryIds: Set<string>;
35+
}
36+
37+
const parseIngestPath = (url: string): string | null => {
38+
try {
39+
const { pathname } = new URL(url, "http://localhost");
40+
const match = pathname.match(/^\/ingest\/([a-zA-Z0-9_-]+)\/?$/);
41+
return match ? match[1] : null;
42+
} catch {
43+
return null;
44+
}
45+
};
46+
3247
export const createServer = async (options: ServerOptions = {}): Promise<ServerResult> => {
3348
const sessionId = options.sessionId || crypto.randomBytes(SESSION_ID_BYTE_LENGTH).toString("hex");
3449
const logDirectory = path.join(options.cwd || os.tmpdir(), LOG_DIRECTORY_NAME);
35-
const logPath = options.logPath || path.join(logDirectory, `debug-${sessionId}.log`);
50+
const primaryLogPath = options.logPath || path.join(logDirectory, `debug-${sessionId}.log`);
3651
const host = options.host || "127.0.0.1";
3752
const port = options.port || 0;
3853

@@ -56,7 +71,20 @@ export const createServer = async (options: ServerOptions = {}): Promise<ServerR
5671
removeServerLock(logDirectory);
5772
}
5873

59-
const processedEntryIds = new Set<string>();
74+
const sessions = new Map<string, SessionState>();
75+
76+
const getSessionState = (requestSessionId: string): SessionState => {
77+
const existing = sessions.get(requestSessionId);
78+
if (existing) return existing;
79+
80+
const sessionLogPath =
81+
requestSessionId === sessionId
82+
? primaryLogPath
83+
: path.join(logDirectory, `debug-${requestSessionId}.log`);
84+
const state: SessionState = { logPath: sessionLogPath, processedEntryIds: new Set() };
85+
sessions.set(requestSessionId, state);
86+
return state;
87+
};
6088

6189
const server = http.createServer((request, response) => {
6290
response.setHeader("Access-Control-Allow-Origin", "*");
@@ -68,25 +96,45 @@ export const createServer = async (options: ServerOptions = {}): Promise<ServerR
6896
return;
6997
}
7098

99+
const url = request.url || "/";
100+
101+
if (url === "/" && request.method === "GET") {
102+
response.writeHead(200, { "Content-Type": "application/json" });
103+
response.end(JSON.stringify({ ok: true }));
104+
return;
105+
}
106+
107+
const requestSessionId = parseIngestPath(url);
108+
if (!requestSessionId) {
109+
response.writeHead(404, { "Content-Type": "application/json" });
110+
response.end(JSON.stringify({ error: "Not found" }));
111+
return;
112+
}
113+
114+
const sessionState = getSessionState(requestSessionId);
115+
71116
if (request.method === "POST") {
72-
let body = "";
73-
request.on("data", (chunk: Buffer) => (body += chunk));
117+
let requestBody = "";
118+
request.on("data", (chunk: Buffer) => (requestBody += chunk));
74119
request.on("end", () => {
75120
try {
76-
const logEntry = JSON.parse(body);
121+
const logEntry = JSON.parse(requestBody);
77122

78-
if (logEntry.id && processedEntryIds.has(logEntry.id)) {
123+
if (logEntry.id && sessionState.processedEntryIds.has(logEntry.id)) {
79124
response.writeHead(200, { "Content-Type": "application/json" });
80125
response.end(JSON.stringify({ ok: true, duplicate: true }));
81126
return;
82127
}
83128

84-
logEntry.sessionId = logEntry.sessionId || sessionId;
129+
logEntry.sessionId = logEntry.sessionId || requestSessionId;
85130
logEntry.timestamp = logEntry.timestamp || Date.now();
86-
fs.appendFileSync(logPath, JSON.stringify(logEntry) + "\n");
131+
fs.appendFileSync(sessionState.logPath, JSON.stringify(logEntry) + "\n");
87132

88133
if (logEntry.id) {
89-
processedEntryIds.add(logEntry.id);
134+
if (sessionState.processedEntryIds.size >= MAX_DEDUP_ENTRIES) {
135+
sessionState.processedEntryIds.clear();
136+
}
137+
sessionState.processedEntryIds.add(logEntry.id);
90138
}
91139

92140
response.writeHead(200, { "Content-Type": "application/json" });
@@ -101,8 +149,8 @@ export const createServer = async (options: ServerOptions = {}): Promise<ServerR
101149

102150
if (request.method === "DELETE") {
103151
try {
104-
if (fs.existsSync(logPath)) fs.unlinkSync(logPath);
105-
processedEntryIds.clear();
152+
if (fs.existsSync(sessionState.logPath)) fs.unlinkSync(sessionState.logPath);
153+
sessionState.processedEntryIds.clear();
106154
response.writeHead(200, { "Content-Type": "application/json" });
107155
response.end(JSON.stringify({ ok: true, cleared: true }));
108156
} catch (error: unknown) {
@@ -114,7 +162,9 @@ export const createServer = async (options: ServerOptions = {}): Promise<ServerR
114162

115163
if (request.method === "GET") {
116164
try {
117-
const logContent = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : "";
165+
const logContent = fs.existsSync(sessionState.logPath)
166+
? fs.readFileSync(sessionState.logPath, "utf-8")
167+
: "";
118168
response.writeHead(200, { "Content-Type": "application/x-ndjson" });
119169
response.end(logContent);
120170
} catch (error: unknown) {
@@ -139,7 +189,7 @@ export const createServer = async (options: ServerOptions = {}): Promise<ServerR
139189
sessionId,
140190
port: serverAddress.port,
141191
endpoint: `http://${host}:${serverAddress.port}/ingest/${sessionId}`,
142-
logPath,
192+
logPath: primaryLogPath,
143193
};
144194

145195
writeServerLock(logDirectory, {
@@ -148,7 +198,7 @@ export const createServer = async (options: ServerOptions = {}): Promise<ServerR
148198
port: serverAddress.port,
149199
sessionId,
150200
endpoint: info.endpoint,
151-
logPath,
201+
logPath: primaryLogPath,
152202
});
153203

154204
server.on("close", () => removeServerLock(logDirectory));

0 commit comments

Comments
 (0)