agent-dbg 0.1.2 → 0.1.3
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/.claude/settings.local.json +3 -1
- package/.claude/skills/agent-dbg/SKILL.md +2 -0
- package/.claude/skills/agent-dbg/references/commands.md +2 -1
- package/bun.lock +60 -0
- package/dist/main.js +236 -75
- package/package.json +1 -1
- package/src/commands/attach.ts +4 -4
- package/src/commands/launch.ts +3 -5
- package/src/commands/logs.ts +58 -16
- package/src/daemon/client.ts +27 -5
- package/src/daemon/entry.ts +12 -2
- package/src/daemon/logger.ts +51 -0
- package/src/daemon/paths.ts +4 -0
- package/src/daemon/server.ts +9 -1
- package/src/daemon/session.ts +48 -10
- package/src/daemon/spawn.ts +27 -4
- package/src/formatter/logs.ts +15 -0
- package/tests/integration/daemon-logging.test.ts +156 -0
- package/tests/unit/daemon-logger.test.ts +117 -0
- package/tests/unit/daemon.test.ts +60 -0
package/package.json
CHANGED
package/src/commands/attach.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { registerCommand } from "../cli/registry.ts";
|
|
2
2
|
import { DaemonClient } from "../daemon/client.ts";
|
|
3
|
-
import {
|
|
3
|
+
import { ensureDaemon } from "../daemon/spawn.ts";
|
|
4
4
|
|
|
5
5
|
registerCommand("attach", async (args) => {
|
|
6
6
|
const session = args.global.session;
|
|
@@ -12,17 +12,17 @@ registerCommand("attach", async (args) => {
|
|
|
12
12
|
return 1;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
// Check if daemon already running
|
|
15
|
+
// Check if daemon already running (PID-aware — stale sockets won't block)
|
|
16
16
|
if (DaemonClient.isRunning(session)) {
|
|
17
17
|
console.error(`Session "${session}" is already active`);
|
|
18
18
|
console.error(` -> Try: agent-dbg stop --session ${session}`);
|
|
19
19
|
return 1;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
//
|
|
22
|
+
// Ensure daemon is running — auto-cleans stale sockets if daemon is dead
|
|
23
23
|
const timeout =
|
|
24
24
|
typeof args.flags.timeout === "string" ? parseInt(args.flags.timeout, 10) : undefined;
|
|
25
|
-
await
|
|
25
|
+
await ensureDaemon(session, { timeout });
|
|
26
26
|
|
|
27
27
|
// Send attach command
|
|
28
28
|
const client = new DaemonClient(session);
|
package/src/commands/launch.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { registerCommand } from "../cli/registry.ts";
|
|
2
2
|
import { DaemonClient } from "../daemon/client.ts";
|
|
3
|
-
import {
|
|
3
|
+
import { ensureDaemon } from "../daemon/spawn.ts";
|
|
4
4
|
import { shortPath } from "../formatter/path.ts";
|
|
5
5
|
|
|
6
6
|
registerCommand("launch", async (args) => {
|
|
@@ -23,10 +23,8 @@ registerCommand("launch", async (args) => {
|
|
|
23
23
|
return 1;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
await spawnDaemon(session, { timeout });
|
|
29
|
-
}
|
|
26
|
+
// Ensure daemon is running — auto-cleans stale sockets if daemon is dead
|
|
27
|
+
await ensureDaemon(session, { timeout });
|
|
30
28
|
|
|
31
29
|
// Send launch command to daemon
|
|
32
30
|
const client = new DaemonClient(session);
|
package/src/commands/logs.ts
CHANGED
|
@@ -10,10 +10,11 @@ import {
|
|
|
10
10
|
} from "node:fs";
|
|
11
11
|
import type { CdpLogEntry } from "../cdp/logger.ts";
|
|
12
12
|
import { registerCommand } from "../cli/registry.ts";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
13
|
+
import type { DaemonLogEntry } from "../daemon/logger.ts";
|
|
14
|
+
import { getDaemonLogPath, getLogPath } from "../daemon/paths.ts";
|
|
15
|
+
import { formatDaemonLogEntry, formatLogEntry } from "../formatter/logs.ts";
|
|
15
16
|
|
|
16
|
-
function
|
|
17
|
+
function parseCdpEntries(text: string): CdpLogEntry[] {
|
|
17
18
|
const entries: CdpLogEntry[] = [];
|
|
18
19
|
for (const line of text.split("\n")) {
|
|
19
20
|
if (!line.trim()) continue;
|
|
@@ -26,11 +27,28 @@ function parseEntries(text: string): CdpLogEntry[] {
|
|
|
26
27
|
return entries;
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
function parseDaemonEntries(text: string): DaemonLogEntry[] {
|
|
31
|
+
const entries: DaemonLogEntry[] = [];
|
|
32
|
+
for (const line of text.split("\n")) {
|
|
33
|
+
if (!line.trim()) continue;
|
|
34
|
+
try {
|
|
35
|
+
entries.push(JSON.parse(line) as DaemonLogEntry);
|
|
36
|
+
} catch {
|
|
37
|
+
// skip malformed lines
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return entries;
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
function filterByDomain(entries: CdpLogEntry[], domain: string): CdpLogEntry[] {
|
|
30
44
|
return entries.filter((e) => e.method.startsWith(`${domain}.`));
|
|
31
45
|
}
|
|
32
46
|
|
|
33
|
-
function
|
|
47
|
+
function filterByLevel(entries: DaemonLogEntry[], level: string): DaemonLogEntry[] {
|
|
48
|
+
return entries.filter((e) => e.level === level);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function printCdpEntries(entries: CdpLogEntry[], json: boolean): void {
|
|
34
52
|
for (const entry of entries) {
|
|
35
53
|
if (json) {
|
|
36
54
|
console.log(JSON.stringify(entry));
|
|
@@ -40,40 +58,58 @@ function printEntries(entries: CdpLogEntry[], json: boolean): void {
|
|
|
40
58
|
}
|
|
41
59
|
}
|
|
42
60
|
|
|
61
|
+
function printDaemonEntries(entries: DaemonLogEntry[], json: boolean): void {
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (json) {
|
|
64
|
+
console.log(JSON.stringify(entry));
|
|
65
|
+
} else {
|
|
66
|
+
console.log(formatDaemonLogEntry(entry));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
43
71
|
registerCommand("logs", async (args) => {
|
|
44
72
|
const session = args.global.session;
|
|
45
|
-
const
|
|
73
|
+
const isDaemon = args.flags.daemon === true;
|
|
74
|
+
const logPath = isDaemon ? getDaemonLogPath(session) : getLogPath(session);
|
|
46
75
|
|
|
47
76
|
// --clear: truncate log file
|
|
48
77
|
if (args.flags.clear === true) {
|
|
49
78
|
if (existsSync(logPath)) {
|
|
50
79
|
writeFileSync(logPath, "");
|
|
51
|
-
console.log("Log cleared
|
|
80
|
+
console.log(`${isDaemon ? "Daemon log" : "Log"} cleared`);
|
|
52
81
|
} else {
|
|
53
|
-
console.log(
|
|
82
|
+
console.log(`No ${isDaemon ? "daemon " : ""}log file to clear`);
|
|
54
83
|
}
|
|
55
84
|
return 0;
|
|
56
85
|
}
|
|
57
86
|
|
|
58
87
|
if (!existsSync(logPath)) {
|
|
59
|
-
console.error(`No log file for session "${session}"`);
|
|
88
|
+
console.error(`No ${isDaemon ? "daemon " : ""}log file for session "${session}"`);
|
|
60
89
|
console.error(" -> Try: agent-dbg launch --brk node app.js");
|
|
61
90
|
return 1;
|
|
62
91
|
}
|
|
63
92
|
|
|
64
93
|
const isJson = args.global.json;
|
|
65
94
|
const domain = typeof args.flags.domain === "string" ? args.flags.domain : undefined;
|
|
95
|
+
const level = typeof args.flags.level === "string" ? args.flags.level : undefined;
|
|
66
96
|
const limit = typeof args.flags.limit === "string" ? parseInt(args.flags.limit, 10) : 50;
|
|
67
97
|
const follow = args.flags.follow === true;
|
|
68
98
|
|
|
69
99
|
// Read existing entries
|
|
70
100
|
const content = readFileSync(logPath, "utf-8");
|
|
71
|
-
let entries = parseEntries(content);
|
|
72
|
-
if (domain) entries = filterByDomain(entries, domain);
|
|
73
101
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
102
|
+
if (isDaemon) {
|
|
103
|
+
let entries = parseDaemonEntries(content);
|
|
104
|
+
if (level) entries = filterByLevel(entries, level);
|
|
105
|
+
const sliced = follow ? entries : entries.slice(-limit);
|
|
106
|
+
printDaemonEntries(sliced, isJson);
|
|
107
|
+
} else {
|
|
108
|
+
let entries = parseCdpEntries(content);
|
|
109
|
+
if (domain) entries = filterByDomain(entries, domain);
|
|
110
|
+
const sliced = follow ? entries : entries.slice(-limit);
|
|
111
|
+
printCdpEntries(sliced, isJson);
|
|
112
|
+
}
|
|
77
113
|
|
|
78
114
|
if (!follow) return 0;
|
|
79
115
|
|
|
@@ -92,9 +128,15 @@ registerCommand("logs", async (args) => {
|
|
|
92
128
|
closeSync(fd);
|
|
93
129
|
offset = size;
|
|
94
130
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
131
|
+
if (isDaemon) {
|
|
132
|
+
let newEntries = parseDaemonEntries(buf.toString("utf-8"));
|
|
133
|
+
if (level) newEntries = filterByLevel(newEntries, level);
|
|
134
|
+
printDaemonEntries(newEntries, isJson);
|
|
135
|
+
} else {
|
|
136
|
+
let newEntries = parseCdpEntries(buf.toString("utf-8"));
|
|
137
|
+
if (domain) newEntries = filterByDomain(newEntries, domain);
|
|
138
|
+
printCdpEntries(newEntries, isJson);
|
|
139
|
+
}
|
|
98
140
|
} catch {
|
|
99
141
|
// File may have been truncated or removed
|
|
100
142
|
}
|
package/src/daemon/client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { type DaemonResponse, DaemonResponseSchema } from "../protocol/messages.ts";
|
|
3
|
-
import { getSocketDir, getSocketPath } from "./paths.ts";
|
|
3
|
+
import { getLockPath, getSocketDir, getSocketPath } from "./paths.ts";
|
|
4
4
|
|
|
5
5
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
6
6
|
|
|
@@ -107,21 +107,43 @@ export class DaemonClient {
|
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Check if a daemon is running for the given session.
|
|
112
|
+
* Uses PID liveness check (Docker-style): reads the lock file PID
|
|
113
|
+
* and verifies the process is actually alive via kill(pid, 0).
|
|
114
|
+
*/
|
|
110
115
|
static isRunning(session: string): boolean {
|
|
111
116
|
const socketPath = getSocketPath(session);
|
|
112
117
|
if (!existsSync(socketPath)) {
|
|
113
118
|
return false;
|
|
114
119
|
}
|
|
115
|
-
|
|
120
|
+
|
|
121
|
+
const lockPath = getLockPath(session);
|
|
122
|
+
if (!existsSync(lockPath)) {
|
|
123
|
+
// Socket exists but no lock file — stale
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
116
127
|
try {
|
|
117
|
-
|
|
118
|
-
|
|
128
|
+
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
|
|
129
|
+
if (Number.isNaN(pid)) return false;
|
|
130
|
+
process.kill(pid, 0); // signal 0 = liveness check
|
|
119
131
|
return true;
|
|
120
132
|
} catch {
|
|
121
133
|
return false;
|
|
122
134
|
}
|
|
123
135
|
}
|
|
124
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Remove stale socket and lock files for a session whose daemon is no longer alive.
|
|
139
|
+
*/
|
|
140
|
+
static cleanStaleFiles(session: string): void {
|
|
141
|
+
const socketPath = getSocketPath(session);
|
|
142
|
+
const lockPath = getLockPath(session);
|
|
143
|
+
if (existsSync(socketPath)) unlinkSync(socketPath);
|
|
144
|
+
if (existsSync(lockPath)) unlinkSync(lockPath);
|
|
145
|
+
}
|
|
146
|
+
|
|
125
147
|
static async isAlive(session: string): Promise<boolean> {
|
|
126
148
|
const socketPath = getSocketPath(session);
|
|
127
149
|
if (!existsSync(socketPath)) {
|
package/src/daemon/entry.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { DaemonRequest, DaemonResponse } from "../protocol/messages.ts";
|
|
2
|
+
import { DaemonLogger } from "./logger.ts";
|
|
3
|
+
import { ensureSocketDir, getDaemonLogPath } from "./paths.ts";
|
|
2
4
|
import { DaemonServer } from "./server.ts";
|
|
3
5
|
import { DebugSession } from "./session.ts";
|
|
4
6
|
|
|
@@ -22,8 +24,16 @@ if (timeoutIdx !== -1) {
|
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
+
ensureSocketDir();
|
|
28
|
+
const daemonLogger = new DaemonLogger(getDaemonLogPath(session));
|
|
29
|
+
daemonLogger.info("daemon.start", `Daemon starting for session "${session}"`, {
|
|
30
|
+
pid: process.pid,
|
|
31
|
+
session,
|
|
32
|
+
timeout,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const server = new DaemonServer(session, { idleTimeout: timeout, logger: daemonLogger });
|
|
36
|
+
const debugSession = new DebugSession(session, { daemonLogger });
|
|
27
37
|
|
|
28
38
|
server.onRequest(async (req: DaemonRequest): Promise<DaemonResponse> => {
|
|
29
39
|
switch (req.cmd) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { appendFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export interface DaemonLogEntry {
|
|
4
|
+
ts: number;
|
|
5
|
+
level: "info" | "warn" | "error" | "debug";
|
|
6
|
+
event: string;
|
|
7
|
+
message: string;
|
|
8
|
+
data?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class DaemonLogger {
|
|
12
|
+
private logPath: string;
|
|
13
|
+
|
|
14
|
+
constructor(logPath: string) {
|
|
15
|
+
this.logPath = logPath;
|
|
16
|
+
writeFileSync(logPath, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
info(event: string, message: string, data?: Record<string, unknown>): void {
|
|
20
|
+
this.write("info", event, message, data);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
warn(event: string, message: string, data?: Record<string, unknown>): void {
|
|
24
|
+
this.write("warn", event, message, data);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
error(event: string, message: string, data?: Record<string, unknown>): void {
|
|
28
|
+
this.write("error", event, message, data);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
debug(event: string, message: string, data?: Record<string, unknown>): void {
|
|
32
|
+
this.write("debug", event, message, data);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
clear(): void {
|
|
36
|
+
writeFileSync(this.logPath, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private write(
|
|
40
|
+
level: DaemonLogEntry["level"],
|
|
41
|
+
event: string,
|
|
42
|
+
message: string,
|
|
43
|
+
data?: Record<string, unknown>,
|
|
44
|
+
): void {
|
|
45
|
+
const entry: DaemonLogEntry = { ts: Date.now(), level, event, message };
|
|
46
|
+
if (data !== undefined) {
|
|
47
|
+
entry.data = data;
|
|
48
|
+
}
|
|
49
|
+
appendFileSync(this.logPath, `${JSON.stringify(entry)}\n`);
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/daemon/paths.ts
CHANGED
|
@@ -22,6 +22,10 @@ export function getLogPath(session: string): string {
|
|
|
22
22
|
return join(getSocketDir(), `${session}.cdp.log`);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export function getDaemonLogPath(session: string): string {
|
|
26
|
+
return join(getSocketDir(), `${session}.daemon.log`);
|
|
27
|
+
}
|
|
28
|
+
|
|
25
29
|
export function ensureSocketDir(): void {
|
|
26
30
|
const dir = getSocketDir();
|
|
27
31
|
if (!existsSync(dir)) {
|
package/src/daemon/server.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
DaemonRequestSchema,
|
|
5
5
|
type DaemonResponse,
|
|
6
6
|
} from "../protocol/messages.ts";
|
|
7
|
+
import type { DaemonLogger } from "./logger.ts";
|
|
7
8
|
import { ensureSocketDir, getLockPath, getSocketPath } from "./paths.ts";
|
|
8
9
|
|
|
9
10
|
type RequestHandler = (req: DaemonRequest) => Promise<DaemonResponse>;
|
|
@@ -16,12 +17,14 @@ export class DaemonServer {
|
|
|
16
17
|
private listener: ReturnType<typeof Bun.listen> | null = null;
|
|
17
18
|
private socketPath: string;
|
|
18
19
|
private lockPath: string;
|
|
20
|
+
private logger: DaemonLogger | null;
|
|
19
21
|
|
|
20
|
-
constructor(session: string, options: { idleTimeout: number }) {
|
|
22
|
+
constructor(session: string, options: { idleTimeout: number; logger?: DaemonLogger }) {
|
|
21
23
|
this.session = session;
|
|
22
24
|
this.idleTimeout = options.idleTimeout;
|
|
23
25
|
this.socketPath = getSocketPath(session);
|
|
24
26
|
this.lockPath = getLockPath(session);
|
|
27
|
+
this.logger = options.logger ?? null;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
onRequest(handler: RequestHandler): void {
|
|
@@ -72,6 +75,7 @@ export class DaemonServer {
|
|
|
72
75
|
},
|
|
73
76
|
close() {},
|
|
74
77
|
error(_socket, error) {
|
|
78
|
+
server.logger?.error("socket.error", error.message);
|
|
75
79
|
console.error(`[daemon] socket error: ${error.message}`);
|
|
76
80
|
},
|
|
77
81
|
},
|
|
@@ -149,6 +153,10 @@ export class DaemonServer {
|
|
|
149
153
|
}
|
|
150
154
|
if (this.idleTimeout > 0) {
|
|
151
155
|
this.idleTimer = setTimeout(() => {
|
|
156
|
+
this.logger?.info(
|
|
157
|
+
"daemon.idle",
|
|
158
|
+
`Idle timeout reached (${this.idleTimeout}s), shutting down`,
|
|
159
|
+
);
|
|
152
160
|
this.stop();
|
|
153
161
|
}, this.idleTimeout * 1000);
|
|
154
162
|
}
|
package/src/daemon/session.ts
CHANGED
|
@@ -6,7 +6,8 @@ import type { RemoteObject } from "../formatter/values.ts";
|
|
|
6
6
|
import { formatValue } from "../formatter/values.ts";
|
|
7
7
|
import { RefTable } from "../refs/ref-table.ts";
|
|
8
8
|
import { SourceMapResolver } from "../sourcemap/resolver.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { DaemonLogger } from "./logger.ts";
|
|
10
|
+
import { ensureSocketDir, getDaemonLogPath, getLogPath } from "./paths.ts";
|
|
10
11
|
import {
|
|
11
12
|
addBlackbox as addBlackboxImpl,
|
|
12
13
|
listBlackbox as listBlackboxImpl,
|
|
@@ -140,7 +141,7 @@ export class DebugSession {
|
|
|
140
141
|
cdp: CdpClient | null = null;
|
|
141
142
|
refs: RefTable = new RefTable();
|
|
142
143
|
sourceMapResolver: SourceMapResolver = new SourceMapResolver();
|
|
143
|
-
childProcess: Subprocess<"ignore", "
|
|
144
|
+
childProcess: Subprocess<"ignore", "ignore", "pipe"> | null = null;
|
|
144
145
|
state: "idle" | "running" | "paused" = "idle";
|
|
145
146
|
pauseInfo: PauseInfo | null = null;
|
|
146
147
|
pausedCallFrames: Protocol.Debugger.CallFrame[] = [];
|
|
@@ -157,11 +158,14 @@ export class DebugSession {
|
|
|
157
158
|
launchCommand: string[] | null = null;
|
|
158
159
|
launchOptions: { brk?: boolean; port?: number } | null = null;
|
|
159
160
|
cdpLogger: CdpLogger;
|
|
161
|
+
daemonLogger: DaemonLogger;
|
|
160
162
|
|
|
161
|
-
constructor(session: string) {
|
|
163
|
+
constructor(session: string, options?: { daemonLogger?: DaemonLogger }) {
|
|
162
164
|
this.session = session;
|
|
163
165
|
ensureSocketDir();
|
|
164
166
|
this.cdpLogger = new CdpLogger(getLogPath(session));
|
|
167
|
+
this.daemonLogger =
|
|
168
|
+
options?.daemonLogger ?? new DaemonLogger(getDaemonLogPath(session));
|
|
165
169
|
}
|
|
166
170
|
|
|
167
171
|
// ── Session lifecycle ─────────────────────────────────────────────
|
|
@@ -192,11 +196,16 @@ export class DebugSession {
|
|
|
192
196
|
|
|
193
197
|
const proc = Bun.spawn(spawnArgs, {
|
|
194
198
|
stdin: "ignore",
|
|
195
|
-
stdout: "
|
|
199
|
+
stdout: "ignore",
|
|
196
200
|
stderr: "pipe",
|
|
197
201
|
});
|
|
198
202
|
this.childProcess = proc;
|
|
199
203
|
|
|
204
|
+
this.daemonLogger.info("child.spawn", `Spawned process pid=${proc.pid}`, {
|
|
205
|
+
pid: proc.pid,
|
|
206
|
+
command: spawnArgs,
|
|
207
|
+
});
|
|
208
|
+
|
|
200
209
|
// Monitor child process exit in the background
|
|
201
210
|
this.monitorProcessExit(proc);
|
|
202
211
|
|
|
@@ -204,6 +213,10 @@ export class DebugSession {
|
|
|
204
213
|
const wsUrl = await this.readInspectorUrl(proc.stderr);
|
|
205
214
|
this.wsUrl = wsUrl;
|
|
206
215
|
|
|
216
|
+
this.daemonLogger.info("inspector.detected", `Inspector URL: ${wsUrl}`, {
|
|
217
|
+
wsUrl,
|
|
218
|
+
});
|
|
219
|
+
|
|
207
220
|
// Connect CDP
|
|
208
221
|
await this.connectCdp(wsUrl);
|
|
209
222
|
|
|
@@ -741,8 +754,10 @@ export class DebugSession {
|
|
|
741
754
|
}
|
|
742
755
|
|
|
743
756
|
private async connectCdp(wsUrl: string): Promise<void> {
|
|
757
|
+
this.daemonLogger.debug("cdp.connecting", `Connecting to ${wsUrl}`);
|
|
744
758
|
const cdp = await CdpClient.connect(wsUrl, this.cdpLogger);
|
|
745
759
|
this.cdp = cdp;
|
|
760
|
+
this.daemonLogger.info("cdp.connected", `CDP connected to ${wsUrl}`);
|
|
746
761
|
|
|
747
762
|
// Set up event handlers before enabling domains so we don't miss any events
|
|
748
763
|
this.setupCdpEventHandlers(cdp);
|
|
@@ -871,9 +886,13 @@ export class DebugSession {
|
|
|
871
886
|
});
|
|
872
887
|
}
|
|
873
888
|
|
|
874
|
-
private monitorProcessExit(proc: Subprocess<"ignore", "
|
|
889
|
+
private monitorProcessExit(proc: Subprocess<"ignore", "ignore", "pipe">): void {
|
|
875
890
|
proc.exited
|
|
876
|
-
.then(() => {
|
|
891
|
+
.then((exitCode) => {
|
|
892
|
+
this.daemonLogger.info("child.exit", `Process exited with code ${exitCode ?? "unknown"}`, {
|
|
893
|
+
pid: proc.pid,
|
|
894
|
+
exitCode: exitCode ?? null,
|
|
895
|
+
});
|
|
877
896
|
// Child process has exited
|
|
878
897
|
this.childProcess = null;
|
|
879
898
|
if (this.cdp) {
|
|
@@ -884,7 +903,10 @@ export class DebugSession {
|
|
|
884
903
|
this.pauseInfo = null;
|
|
885
904
|
this.onProcessExit?.();
|
|
886
905
|
})
|
|
887
|
-
.catch(() => {
|
|
906
|
+
.catch((err) => {
|
|
907
|
+
this.daemonLogger.error("child.exit.error", `Error waiting for process exit: ${err}`, {
|
|
908
|
+
pid: proc.pid,
|
|
909
|
+
});
|
|
888
910
|
// Error waiting for exit, treat as exited
|
|
889
911
|
this.childProcess = null;
|
|
890
912
|
this.state = "idle";
|
|
@@ -907,13 +929,16 @@ export class DebugSession {
|
|
|
907
929
|
if (done) {
|
|
908
930
|
break;
|
|
909
931
|
}
|
|
910
|
-
|
|
932
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
933
|
+
accumulated += chunk;
|
|
934
|
+
this.daemonLogger.debug("child.stderr", chunk.trimEnd());
|
|
911
935
|
|
|
912
936
|
const match = INSPECTOR_URL_REGEX.exec(accumulated);
|
|
913
937
|
if (match?.[1]) {
|
|
914
938
|
clearTimeout(timeout);
|
|
915
|
-
//
|
|
916
|
-
|
|
939
|
+
// Continue draining stderr in the background so proc.exited
|
|
940
|
+
// can resolve (Bun requires all piped streams to be consumed).
|
|
941
|
+
this.drainReader(reader);
|
|
917
942
|
return match[1];
|
|
918
943
|
}
|
|
919
944
|
}
|
|
@@ -922,6 +947,10 @@ export class DebugSession {
|
|
|
922
947
|
}
|
|
923
948
|
|
|
924
949
|
clearTimeout(timeout);
|
|
950
|
+
this.daemonLogger.error("inspector.failed", "Failed to detect inspector URL", {
|
|
951
|
+
stderr: accumulated.slice(0, 2000),
|
|
952
|
+
timeoutMs: INSPECTOR_TIMEOUT_MS,
|
|
953
|
+
});
|
|
925
954
|
throw new Error(
|
|
926
955
|
`Failed to detect inspector URL within ${INSPECTOR_TIMEOUT_MS}ms. Stderr: ${accumulated.slice(0, 500)}`,
|
|
927
956
|
);
|
|
@@ -955,4 +984,13 @@ export class DebugSession {
|
|
|
955
984
|
|
|
956
985
|
return wsUrl;
|
|
957
986
|
}
|
|
987
|
+
|
|
988
|
+
private drainReader(reader: { read(): Promise<{ done: boolean; value?: Uint8Array }> }): void {
|
|
989
|
+
const pump = (): void => {
|
|
990
|
+
reader.read().then(({ done }) => {
|
|
991
|
+
if (!done) pump();
|
|
992
|
+
}).catch(() => {});
|
|
993
|
+
};
|
|
994
|
+
pump();
|
|
995
|
+
}
|
|
958
996
|
}
|
package/src/daemon/spawn.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, openSync } from "node:fs";
|
|
2
|
+
import { DaemonClient } from "./client.ts";
|
|
3
|
+
import { ensureSocketDir, getDaemonLogPath, getSocketPath } from "./paths.ts";
|
|
3
4
|
|
|
4
5
|
const POLL_INTERVAL_MS = 50;
|
|
5
6
|
const SPAWN_TIMEOUT_MS = 5000;
|
|
@@ -30,11 +31,16 @@ export async function spawnDaemon(
|
|
|
30
31
|
spawnArgs.push("--timeout", String(options.timeout));
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
// Redirect daemon stdout/stderr to daemon log file so crashes are captured
|
|
35
|
+
// even before the DaemonLogger initializes inside the child process.
|
|
36
|
+
ensureSocketDir();
|
|
37
|
+
const logFd = openSync(getDaemonLogPath(session), "a");
|
|
38
|
+
|
|
33
39
|
const proc = Bun.spawn(spawnArgs, {
|
|
34
40
|
detached: true,
|
|
35
41
|
stdin: "ignore",
|
|
36
|
-
stdout:
|
|
37
|
-
stderr:
|
|
42
|
+
stdout: logFd,
|
|
43
|
+
stderr: logFd,
|
|
38
44
|
});
|
|
39
45
|
|
|
40
46
|
// Unref so the parent process can exit
|
|
@@ -51,3 +57,20 @@ export async function spawnDaemon(
|
|
|
51
57
|
|
|
52
58
|
throw new Error(`Daemon for session "${session}" failed to start within ${SPAWN_TIMEOUT_MS}ms`);
|
|
53
59
|
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Ensure a daemon is running for the session. If the socket exists but the
|
|
63
|
+
* daemon process is dead (stale), cleans up and respawns automatically.
|
|
64
|
+
*/
|
|
65
|
+
export async function ensureDaemon(
|
|
66
|
+
session: string,
|
|
67
|
+
options?: { port?: number; timeout?: number },
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
if (DaemonClient.isRunning(session)) return;
|
|
70
|
+
|
|
71
|
+
// Clean up stale socket/lock files before spawning, otherwise
|
|
72
|
+
// spawnDaemon's poll loop would see the old socket and return immediately.
|
|
73
|
+
DaemonClient.cleanStaleFiles(session);
|
|
74
|
+
|
|
75
|
+
await spawnDaemon(session, options);
|
|
76
|
+
}
|
package/src/formatter/logs.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CdpLogEntry } from "../cdp/logger.ts";
|
|
2
|
+
import type { DaemonLogEntry } from "../daemon/logger.ts";
|
|
2
3
|
|
|
3
4
|
function formatTime(ts: number): string {
|
|
4
5
|
const d = new Date(ts);
|
|
@@ -108,3 +109,17 @@ export function formatLogEntry(entry: CdpLogEntry): string {
|
|
|
108
109
|
const summary = summarizer ? summarizer(entry) : summarizeParams(entry);
|
|
109
110
|
return `${time} <- ${entry.method}${summary ? ` ${summary}` : ""}`;
|
|
110
111
|
}
|
|
112
|
+
|
|
113
|
+
const levelColors: Record<string, string> = {
|
|
114
|
+
info: "INFO ",
|
|
115
|
+
warn: "WARN ",
|
|
116
|
+
error: "ERROR",
|
|
117
|
+
debug: "DEBUG",
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export function formatDaemonLogEntry(entry: DaemonLogEntry): string {
|
|
121
|
+
const time = `[${formatTime(entry.ts)}]`;
|
|
122
|
+
const level = levelColors[entry.level] ?? entry.level.toUpperCase();
|
|
123
|
+
const data = entry.data ? ` ${truncate(JSON.stringify(entry.data), 120)}` : "";
|
|
124
|
+
return `${time} ${level} ${entry.event}: ${entry.message}${data}`;
|
|
125
|
+
}
|