@siuver/omp-debug-mode 0.1.0 → 0.1.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2 - 2026-08-20
4
+
5
+ - Added an interactive round-review menu with Mark as fixed, Proceed, Add reproduction details, and Abort actions.
6
+ - Added `/debug-review` to reopen the menu and `/debug-note <details>` to return to the editor, add user evidence, and continue the workflow.
7
+ - Kept `/debug-done fixed|proceed` as compatible shortcuts and added interactive confirmation for zero-log completion, zero-log progression, and abort.
8
+
9
+ ## 0.1.1 - 2026-08-20
10
+
11
+ - Replaced the localhost HTTP collector with direct JSONL file appends from instrumented runtime code.
12
+ - The injected prompt now supplies the exact absolute log file and explicitly forbids POST, sockets, and other network transports.
13
+ - Updated log analysis, status reporting, and session resume to read JSONL directly; active probes use a stable `current.jsonl` path while completed rounds are archived by run id.
14
+
3
15
  ## 0.1.0 - 2026-08-20
4
16
 
5
17
  - Initial npm release of the Cursor Debug Mode replica for OMP.
package/README.md CHANGED
@@ -9,28 +9,50 @@ The plugin makes the agent form hypotheses, add temporary runtime probes, attemp
9
9
  | Command | Purpose |
10
10
  | --- | --- |
11
11
  | `/debug-mode <problem>` | Starts a debugging session from a symptom, expected result, actual result, and reproduction description. |
12
+ | `/debug-review` | Reopens the interactive action menu for the completed round. |
13
+ | `/debug-note <details>` | Adds reproduction details from the editor and starts the next evidence-driven round. |
12
14
  | `/debug-status` | Shows the current phase, round, run, live probes, and captured log counts. |
13
- | `/debug-done fixed` | Confirms the fix and asks the agent to remove every probe and summarize the root cause and final change. |
14
- | `/debug-done proceed` | Reports that the issue remains, asks the agent to analyze the captured logs, and starts another hypothesis/instrument/fix round. |
15
- | `/debug-abort` | Stops debug mode and removes its logs while leaving code changes in the working tree. |
15
+ | `/debug-done fixed` | Non-menu shortcut that confirms the fix and asks the agent to remove every probe and summarize the result. |
16
+ | `/debug-done proceed` | Non-menu shortcut that analyzes the captured logs and starts another hypothesis/instrument/fix round. |
17
+ | `/debug-abort` | Stops debug mode and removes its logs after confirmation while leaving code changes in the working tree. |
18
+
19
+ ## Interactive Round Review
20
+
21
+ When the Agent completes a debugging round, the plugin enters the reproduction gate and opens an action menu:
22
+
23
+ - **Mark as fixed** - asks the Agent to clean up probes and summarize the root cause and fix. If the round captured no runtime logs, the plugin asks for confirmation first.
24
+ - **Proceed with captured logs** - refreshes the log file at selection time, archives the completed run, and starts the next analysis and fix round. If no runtime observations exist, the plugin asks for confirmation before proceeding.
25
+ - **Add reproduction details** - closes the menu and pre-fills the editor with `/debug-note ` so you can add symptoms, environment details, or reproduction results before continuing.
26
+ - **Abort debug mode** - asks for confirmation, removes debug logs, and keeps applied code changes.
27
+
28
+ Pressing `Esc` simply closes the menu and leaves the workflow at the reproduction gate. After reproducing the issue in another application, run `/debug-review` to reopen it. The existing `/debug-done fixed|proceed` commands remain available for non-interactive and shortcut use.
16
29
 
17
30
  ## Workflow
18
31
 
19
32
  1. Run `/debug-mode <problem description>`.
20
- 2. The agent investigates, records hypotheses, inserts minimal probes marked with `@omp-probe <id>`, and attempts a fix.
21
- 3. When the agent stops, reproduce the issue in the real application.
22
- 4. Run `/debug-done fixed` if the issue is resolved, or `/debug-done proceed` to analyze evidence and continue.
23
- 5. On success, the agent removes all probes, verifies the probe ledger is empty, and summarizes the root cause and fix.
33
+ 2. The Agent investigates, records hypotheses, inserts minimal probes marked with `@omp-probe <id>`, and attempts a fix. The injected prompt strongly requires every probe to append runtime observations directly to the exact JSONL path; console output is supplemental only.
34
+ 3. When the Agent stops, dismiss the review menu if necessary and reproduce the issue in the real application so the instrumented code writes its observations.
35
+ 4. Reopen `/debug-review` and mark the issue fixed, proceed with logs, or return to the editor to submit `/debug-note <details>`.
24
36
 
25
- The extension also provides the read-only `get_debug_logs` and `list_debug_probes` tools so the agent can inspect runtime evidence and verify cleanup.
37
+ The extension also provides the read-only `get_debug_logs` and `list_debug_probes` tools so the Agent can inspect runtime evidence and verify cleanup.
26
38
 
27
39
  ## Runtime Data
28
40
 
29
- During an active session, the plugin starts a local HTTP server on `127.0.0.1:7842`. Instrumentation posts `{ "probe": "...", "data": ... }` to `/log`, and the plugin appends the entries to `<project>/.omp/debug/<run>.jsonl`.
41
+ At the start of each round, the plugin creates an absolute log path:
42
+
43
+ ```text
44
+ <project>/.omp/debug/current.jsonl
45
+ ```
46
+
47
+ That exact stable path is injected into the Agent prompt. The prompt requires every runtime probe to use the target environment's native file append API and write one compact JSON object plus a newline using this schema:
48
+
49
+ ```json
50
+ {"probe":"player-state","ts":1787193600000,"data":{"isGrounded":false}}
51
+ ```
30
52
 
31
- The server only listens on localhost. Debug data may still contain values captured from your application, so review probe payloads before reproducing sensitive workflows. Finishing or aborting debug mode removes the debug log directory. Applied fixes remain in the working tree for review.
53
+ If the log file contains zero entries, treat that as instrumentation or execution-path evidence. Check the build, path permissions, code path, and file append errors; do not ask the user to copy Unity Console output. Use `/debug-note` for reproduction details, not manual console transcription.
32
54
 
33
- Debug state is recorded in the OMP session so an active workflow can resume after reopening the session. Port `7842` must be available for probes to report data.
55
+ When you proceed from the review menu, `/debug-done proceed`, or `/debug-note`, the plugin archives the completed file as `<run-id>.jsonl`, creates an empty `current.jsonl` for the next reproduction, and makes the Agent analyze the archived evidence through `get_debug_logs`. `/debug-status` shows the current absolute file path and log count.
34
56
 
35
57
  ## Install
36
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siuver/omp-debug-mode",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "A Cursor Debug Mode replica for evidence-driven, human-in-the-loop debugging in oh-my-pi.",
6
6
  "license": "MIT",
@@ -0,0 +1,46 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export const ACTIVE_LOG_FILE = "current.jsonl";
5
+
6
+ export function resolveRunLogFile(
7
+ debugDir: string | null,
8
+ run: string | null,
9
+ activeRun: string | null,
10
+ ): string | null {
11
+ if (!debugDir || !run) return null;
12
+ return path.join(debugDir, run === activeRun ? ACTIVE_LOG_FILE : `${run}.jsonl`);
13
+ }
14
+
15
+ export function readJsonlLines(file: string | null): string[] {
16
+ if (!file) return [];
17
+ try {
18
+ return fs
19
+ .readFileSync(file, "utf8")
20
+ .split(/\r?\n/)
21
+ .filter(line => line.trim().length > 0);
22
+ } catch {
23
+ return [];
24
+ }
25
+ }
26
+
27
+ export function prepareRunLog(debugDir: string, previousRun: string | null): string {
28
+ const activeFile = path.join(debugDir, ACTIVE_LOG_FILE);
29
+ const archivedFile = previousRun ? path.join(debugDir, `${previousRun}.jsonl`) : null;
30
+
31
+ try {
32
+ if (archivedFile) {
33
+ if (fs.existsSync(activeFile)) fs.copyFileSync(activeFile, archivedFile);
34
+ else fs.writeFileSync(archivedFile, "", { flag: "a" });
35
+ }
36
+ fs.writeFileSync(activeFile, "", { flag: "w" });
37
+ return activeFile;
38
+ } catch (error) {
39
+ if (archivedFile) {
40
+ try {
41
+ fs.rmSync(archivedFile, { force: true });
42
+ } catch {}
43
+ }
44
+ throw error;
45
+ }
46
+ }
package/src/main.ts CHANGED
@@ -5,29 +5,27 @@
5
5
  * IDLE → /debug-mode <problem>
6
6
  * → agent hypothesizes, adds @omp-probe instrumentation, attempts a fix
7
7
  * → WAITING_REPRO: agent stops; user reproduces out-of-band
8
- * → /debug-done fixed → agent removes probes + summarizes → teardown (logs deleted)
9
- * → /debug-done proceed → agent analyzes captured logs, re-instruments + fixes → loop
8
+ * → Mark as fixed → agent removes probes + summarizes → teardown (logs deleted)
9
+ * → Proceed/add details → agent analyzes logs, re-instruments + fixes → loop
10
10
  *
11
- * Components:
12
- * - Local log server (Bun.serve, 127.0.0.1:7842) probes POST {probe, data};
13
- * logs append to <cwd>/.omp/debug/<run>.jsonl
11
+ * - Stable JSONL file at <cwd>/.omp/debug/current.jsonl — runtime probes append
12
+ * observations directly with the target environment's native file APIs
14
13
  * - Probe ledger: tool_call interception on edit/write records @omp-probe ids;
15
14
  * ground truth is rescanned from disk (checkLedger)
16
15
  * - Tools: get_debug_logs, list_debug_probes
17
- * - Commands: /debug-mode, /debug-status, /debug-done fixed|proceed, /debug-abort
16
+ * - Commands: /debug-mode, /debug-review, /debug-note, /debug-status, /debug-done, /debug-abort
18
17
  */
19
18
  import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
20
- import type { Server } from "bun";
21
- import type { WriteStream } from "node:fs";
22
19
  import * as fs from "node:fs";
23
20
  import * as path from "node:path";
21
+ import { ACTIVE_LOG_FILE, prepareRunLog, readJsonlLines, resolveRunLogFile } from "./log-files";
22
+ import { REVIEW_ABORT, REVIEW_ADD_DETAILS, REVIEW_MARK_FIXED, REVIEW_OPTIONS, REVIEW_PROCEED } from "./review-actions";
24
23
 
25
24
  const DEBUG_ENTRY = "com.omp.debug-mode.state";
26
25
  const PROBE_MARK = /@omp-probe\s+([A-Za-z0-9_-]+)/g;
27
- const DEFAULT_PORT = 7842;
28
-
29
26
  type Phase = "idle" | "round" | "waiting" | "cleanup";
30
27
 
28
+
31
29
  interface Probe {
32
30
  id: string;
33
31
  file: string;
@@ -47,17 +45,10 @@ interface DebugState {
47
45
  cleanupReady: boolean;
48
46
  }
49
47
 
50
- interface LogEntry {
51
- run: string;
52
- probe: string;
53
- ts: number;
54
- data: unknown;
55
- }
56
48
 
57
49
  function freshState(): DebugState {
58
50
  return {
59
51
  active: false,
60
- phase: "idle",
61
52
  problem: "",
62
53
  round: 0,
63
54
  runId: null,
@@ -76,6 +67,10 @@ function probeIdsIn(text: unknown): string[] {
76
67
  return ids;
77
68
  }
78
69
 
70
+ function logFileFor(s: DebugState, run = s.runId): string | null {
71
+ return resolveRunLogFile(s.debugDir, run, s.runId);
72
+ }
73
+
79
74
  function blackboard(s: DebugState): string {
80
75
  const probes = s.probes.map(p => `${p.id} (${p.file}, round ${p.round})`).join("\n ") || "(none)";
81
76
  const counts = Object.entries(s.logCounts)
@@ -90,8 +85,9 @@ ${s.problem}
90
85
  Deployed probes (ground truth, maintained by the extension):
91
86
  ${probes}
92
87
 
93
- Log endpoint for probes: POST JSON {probe, data} to http://127.0.0.1:${DEFAULT_PORT}/log
94
- Probe code MUST carry the marker comment \`// @omp-probe <id>\` adjacent to it.
88
+ Current run log file (absolute path): ${logFileFor(s) ?? "(not initialized)"}
89
+ Runtime probes MUST append JSONL directly to that exact file using the target environment's native file APIs.
90
+ Console output such as Unity Debug.Log may supplement diagnostics but is never the runtime evidence for this workflow. Never ask the user to transcribe console output.
95
91
  Logs by run: ${counts}
96
92
  Current run id: ${s.runId ?? "(not started)"}`;
97
93
  }
@@ -101,99 +97,85 @@ const METHODOLOGY = `\
101
97
  Each round consists of, in order:
102
98
  1. Restate hypotheses (mark each: pending / ruled-out / confirmed via runtime evidence).
103
99
  2. Update instrumentation: remove probes that yielded no information, add probes that
104
- discriminate between remaining hypotheses. Each probe is one minimal statement sending
105
- JSON via HTTP POST to the log endpoint, with the marker comment \`// @omp-probe <id>\`
106
- adjacent (language-appropriate; keep the block small and easy to remove).
100
+ discriminate between remaining hypotheses. Every runtime probe MUST append exactly
101
+ one compact JSON object plus a newline to the exact absolute log file shown above.
102
+ Use {"probe":"<id>","ts":<epoch-ms>,"data":<JSON-serializable-observation>}.
103
+ Append; never overwrite or truncate. Open, append, flush, and close promptly for each
104
+ observation. Do not use HTTP, POST, localhost, sockets, or any network transport.
105
+ Use the target environment's native file API and ensure the code path writes the file.
106
+ Console logging may supplement the file but never replaces it. Do not ask the user to
107
+ copy or summarize console output.
107
108
  3. ATTEMPT A FIX for your leading hypothesis in the same round. Instrumentation without
108
109
  a fix is an incomplete round — the loop only advances when you fix something.
109
110
  4. End the round by writing concise reproduction steps for the user (exact commands or
110
- actions, what to observe). Then STOP — the user reproduces out-of-band and answers
111
- with /debug-done fixed or /debug-done proceed.
111
+ actions, what to observe). Then STOP — the user reproduces out-of-band and uses
112
+ the review menu, /debug-note, or /debug-done fixed|proceed.
112
113
  When told the fix is confirmed: remove every probe, verify with the list_debug_probes
113
114
  tool that the ledger is empty, then summarize root cause and the final fix.`;
114
115
 
115
116
  export default function debugModeExtension(pi: ExtensionAPI) {
116
117
  const z = pi.zod;
117
118
  const state: DebugState = freshState();
118
- let server: Server | null = null;
119
- let logBuffer: LogEntry[] = [];
120
- let pendingWrite: WriteStream | null = null;
119
+ let reviewMenuOpen = false;
121
120
  let uiCtx: ExtensionContext | null = null;
122
121
 
123
- // ============================== log server ==============================
122
+ // ============================== log files ==============================
124
123
 
125
- function startServer(ctx: ExtensionContext, port: number): boolean {
126
- if (server) return true;
127
- const dir = path.join(ctx.cwd, ".omp", "debug");
124
+ function initializeLogDirectory(ctx: ExtensionContext): boolean {
125
+ const dir = path.resolve(ctx.cwd, ".omp", "debug");
128
126
  try {
129
127
  fs.mkdirSync(dir, { recursive: true });
130
- } catch (err) {
131
- pi.logger.error("debug-mode: cannot create log dir", { dir, err });
132
- return false;
133
- }
134
- state.debugDir = dir;
135
- logBuffer = [];
136
- try {
137
- server = Bun.serve({
138
- port,
139
- hostname: "127.0.0.1",
140
- idleTimeout: 5,
141
- async fetch(req) {
142
- const url = new URL(req.url);
143
- if (req.method === "POST" && url.pathname === "/log") {
144
- try {
145
- const body = (await req.json()) as { probe?: unknown; data?: unknown };
146
- if (typeof body.probe !== "string") return new Response("bad probe", { status: 400 });
147
- const entry: LogEntry = {
148
- run: state.runId ?? "orphan",
149
- probe: body.probe,
150
- ts: Date.now(),
151
- data: body.data ?? null,
152
- };
153
- logBuffer.push(entry);
154
- state.logCounts[entry.run] = (state.logCounts[entry.run] ?? 0) + 1;
155
- pendingWrite?.write(JSON.stringify(entry) + "\n");
156
- refreshUi();
157
- return new Response("ok");
158
- } catch {
159
- return new Response("bad json", { status: 400 });
160
- }
161
- }
162
- if (req.method === "GET" && url.pathname === "/health") {
163
- return new Response("ok");
164
- }
165
- return new Response("not found", { status: 404 });
166
- },
167
- });
128
+ state.debugDir = dir;
168
129
  return true;
169
130
  } catch (err) {
170
- pi.logger.error("debug-mode: log server failed to start", { err });
171
- server = null;
131
+ pi.logger.error("debug-mode: cannot create log dir", { dir, err });
132
+ state.debugDir = null;
172
133
  return false;
173
134
  }
174
135
  }
175
136
 
176
- function stopServer(): void {
177
- pendingWrite?.end();
178
- pendingWrite = null;
179
- server?.stop(true);
180
- server = null;
137
+ function readRunLines(run: string): string[] {
138
+ return readJsonlLines(logFileFor(state, run));
139
+ }
140
+
141
+ function refreshLogCounts(): void {
142
+ const runs = new Set(Object.keys(state.logCounts));
143
+ if (state.debugDir) {
144
+ try {
145
+ for (const file of fs.readdirSync(state.debugDir)) {
146
+ if (!file.endsWith(".jsonl")) continue;
147
+ if (file === ACTIVE_LOG_FILE) {
148
+ if (state.runId) runs.add(state.runId);
149
+ } else {
150
+ runs.add(file.replace(/\.jsonl$/, ""));
151
+ }
152
+ }
153
+ } catch {}
154
+ }
155
+ for (const run of runs) state.logCounts[run] = readRunLines(run).length;
181
156
  }
182
157
 
183
- function newRun(): string {
184
- pendingWrite?.end();
185
- pendingWrite = null;
158
+ function newRun(): string | null {
159
+ if (!state.debugDir) return null;
186
160
  const run = `run${state.round}-${Date.now().toString(36)}`;
161
+ try {
162
+ prepareRunLog(state.debugDir, state.runId);
163
+ } catch (err) {
164
+ pi.logger.error("debug-mode: cannot initialize run log", {
165
+ file: path.join(state.debugDir, ACTIVE_LOG_FILE),
166
+ err,
167
+ });
168
+ return null;
169
+ }
187
170
  state.runId = run;
188
171
  state.logCounts[run] = 0;
189
- if (state.debugDir) {
190
- pendingWrite = fs.createWriteStream(path.join(state.debugDir, `${run}.jsonl`), { flags: "a" });
191
- }
192
172
  return run;
193
173
  }
194
174
 
175
+
195
176
  // ============================== probe ledger ==============================
196
177
 
178
+
197
179
  pi.on("tool_call", async (event) => {
198
180
  if (!state.active) return;
199
181
  if (event.toolName !== "edit" && event.toolName !== "write") return;
@@ -205,8 +187,7 @@ export default function debugModeExtension(pi: ExtensionAPI) {
205
187
  if (!state.probes.some(p => p.id === id)) state.probes.push({ id, file, round: state.round });
206
188
  }
207
189
  }
208
- // removal detection: edit inputs alone can't prove deletion — checkLedger()
209
- // rescans files for ground truth on demand.
190
+ // Removal detection rescans files for ground truth on demand.
210
191
  });
211
192
 
212
193
  /** Ground truth: which registered probes still exist in code. */
@@ -247,8 +228,9 @@ export default function debugModeExtension(pi: ExtensionAPI) {
247
228
  ctx.ui.setStatus("debug-mode", ctx.ui.theme.fg("warning", label));
248
229
  const lines: string[] = [];
249
230
  if (state.phase === "waiting") {
231
+ refreshLogCounts();
250
232
  const n = state.runId ? (state.logCounts[state.runId] ?? 0) : 0;
251
- lines.push(ctx.ui.theme.fg("accent", "reproduce the bug, then: /debug-done fixed | proceed"));
233
+ lines.push(ctx.ui.theme.fg("accent", "reproduce the bug, then: /debug-review"));
252
234
  lines.push(ctx.ui.theme.fg("dim", `run ${state.runId} — ${n} log entries`));
253
235
  }
254
236
  ctx.ui.setWidget("debug-mode", lines.length ? lines : undefined);
@@ -293,17 +275,21 @@ export default function debugModeExtension(pi: ExtensionAPI) {
293
275
  pi.on("session_stop", async (_event, ctx) => {
294
276
  if (!state.active) return;
295
277
  if (state.phase === "cleanup") {
296
- // cleanup turn settled teardown (fixes kept, logs + server removed)
297
- if (state.cleanupReady) await teardown(ctx, true);
278
+ // Cleanup turn settled: keep fixes and remove the temporary logs.
279
+ if (state.cleanupReady) await teardown(ctx, "finished");
298
280
  return;
299
281
  }
300
282
  if (state.phase !== "round" || !state.hasRoundContent) return;
301
283
  state.phase = "waiting";
284
+ pi.appendEntry(DEBUG_ENTRY, { ...state });
302
285
  refreshUi();
303
286
  ctx.ui.notify(
304
- `Debug round ${state.round} paused.\nReproduce the bug now, then run:\n /debug-done fixed — fix confirmed, clean up & summarize\n /debug-done proceed — analyze logs, next round`,
287
+ `Debug round ${state.round} paused. Reproduce the bug, then choose an action or run /debug-review.`,
305
288
  "info",
306
289
  );
290
+ if (ctx.hasUI) {
291
+ void openReviewMenu(ctx).catch(err => pi.logger.warn("debug-mode: review menu failed", { err }));
292
+ }
307
293
  });
308
294
 
309
295
  pi.registerCommand("debug-mode", {
@@ -311,7 +297,7 @@ export default function debugModeExtension(pi: ExtensionAPI) {
311
297
  handler: async (args, ctx) => {
312
298
  uiCtx = ctx;
313
299
  if (state.active) {
314
- ctx.ui.notify("debug-mode: already active (use /debug-done or /debug-abort)", "error");
300
+ ctx.ui.notify("debug-mode: already active (use /debug-review, /debug-done, or /debug-abort)", "error");
315
301
  return;
316
302
  }
317
303
  const problem = args.trim();
@@ -324,6 +310,10 @@ export default function debugModeExtension(pi: ExtensionAPI) {
324
310
  });
325
311
 
326
312
  function startDebug(ctx: ExtensionContext, problem: string): void {
313
+ if (!initializeLogDirectory(ctx)) {
314
+ ctx.ui.notify("debug-mode: could not create the debug log directory; debug mode was not started", "error");
315
+ return;
316
+ }
327
317
  state.active = true;
328
318
  state.phase = "round";
329
319
  state.problem = problem;
@@ -331,72 +321,110 @@ export default function debugModeExtension(pi: ExtensionAPI) {
331
321
  state.probes = [];
332
322
  state.logCounts = {};
333
323
  state.hasRoundContent = false;
334
- if (!startServer(ctx, DEFAULT_PORT)) {
335
- ctx.ui.notify("debug-mode: log server failed to start; probes will have nowhere to report", "error");
324
+ if (!newRun()) {
325
+ Object.assign(state, freshState());
326
+ ctx.ui.notify("debug-mode: could not initialize the run log file; debug mode was not started", "error");
327
+ return;
336
328
  }
337
- newRun();
338
329
  refreshUi();
339
330
  pi.sendMessage(
340
331
  {
341
332
  customType: "debug-mode-start",
342
- content: `Starting debug mode. Problem report:\n\n${problem}\n\nBegin round 1: explore, hypothesize, instrument, and attempt a fix. Then give reproduction steps and stop.`,
333
+ content: `Starting debug mode. Problem report:\n\n${problem}\n\nBegin round 1: explore, hypothesize, instrument with direct file writes, and attempt a fix. Then give reproduction steps and stop.`,
343
334
  display: true,
344
335
  },
345
336
  { triggerTurn: true },
346
337
  );
347
338
  }
348
339
 
349
- function finishDebug(ctx: ExtensionContext, verdict: "fixed" | "proceed"): void {
340
+ function ensureWaiting(ctx: ExtensionContext): boolean {
350
341
  if (!state.active) {
351
342
  ctx.ui.notify("debug-mode: not active", "error");
352
- return;
343
+ return false;
353
344
  }
354
345
  if (state.phase !== "waiting") {
355
346
  ctx.ui.notify(`debug-mode: not waiting for reproduction (phase: ${state.phase})`, "error");
356
- return;
347
+ return false;
357
348
  }
358
- if (verdict === "fixed") {
359
- state.phase = "cleanup";
360
- state.cleanupReady = false;
361
- refreshUi();
362
- pi.sendMessage(
363
- {
364
- customType: "debug-mode-fixed",
365
- content:
366
- "User marked the problem FIXED.\n" +
367
- "1. Remove every debug probe from the code (probe ledger below; verify with list_debug_probes after edits).\n" +
368
- "2. Then summarize: root cause, the fix applied, what remains in the working diff.\n" +
369
- `Probe ledger: ${JSON.stringify(state.probes)}`,
370
- display: true,
371
- },
372
- { triggerTurn: true },
349
+ return true;
350
+ }
351
+
352
+ async function markDebugFixed(ctx: ExtensionContext): Promise<void> {
353
+ if (!ensureWaiting(ctx)) return;
354
+ refreshLogCounts();
355
+ const logCount = state.runId ? (state.logCounts[state.runId] ?? 0) : 0;
356
+ if (logCount === 0 && ctx.hasUI) {
357
+ const confirmed = await ctx.ui.confirm(
358
+ "Mark as fixed without runtime logs?",
359
+ "No runtime observations were captured for this round. Mark the problem as fixed anyway?",
373
360
  );
374
- // teardown happens when the cleanup turn settles (session_stop above)
375
- } else {
376
- const run = state.runId ?? "(none)";
377
- const n = state.logCounts[run] ?? 0;
378
- state.round += 1;
379
- state.phase = "round";
380
- state.hasRoundContent = false;
381
- newRun();
382
- refreshUi();
383
- pi.sendMessage(
384
- {
385
- customType: "debug-mode-proceed",
386
- content:
387
- `User chose PROCEED the fix did not resolve it (run ${run} captured ${n} log entries).\n` +
388
- (n === 0
389
- ? "No logs were captured: the probes may not have executed (wrong code path, not rebuilt, or server unreachable) — treat that as a signal.\n"
390
- : "") +
391
- "Read the logs with get_debug_logs, rule out / strengthen hypotheses, re-instrument, attempt the next fix, give reproduction steps, and stop.",
392
- display: true,
393
- },
394
- { triggerTurn: true },
361
+ if (!confirmed) return;
362
+ }
363
+ if (!state.active || state.phase !== "waiting") return;
364
+
365
+ state.phase = "cleanup";
366
+ state.cleanupReady = false;
367
+ refreshUi();
368
+ pi.sendMessage(
369
+ {
370
+ customType: "debug-mode-fixed",
371
+ content:
372
+ "User marked the problem FIXED.\n" +
373
+ "1. Remove every debug probe from the code (probe ledger below; verify with list_debug_probes after edits).\n" +
374
+ "2. Then summarize: root cause, the fix applied, what remains in the working diff.\n" +
375
+ `Probe ledger: ${JSON.stringify(state.probes)}`,
376
+ display: true,
377
+ },
378
+ { triggerTurn: true },
379
+ );
380
+ }
381
+
382
+ async function advanceDebug(ctx: ExtensionContext, reproductionDetails?: string): Promise<void> {
383
+ if (!ensureWaiting(ctx)) return;
384
+ refreshLogCounts();
385
+ const run = state.runId ?? "(none)";
386
+ const logCount = state.logCounts[run] ?? 0;
387
+ if (logCount === 0 && !reproductionDetails && ctx.hasUI) {
388
+ const confirmed = await ctx.ui.confirm(
389
+ "Proceed without runtime logs?",
390
+ "No runtime observations were captured for this round. Continue to another analysis and fix round anyway?",
395
391
  );
392
+ if (!confirmed) return;
393
+ }
394
+ if (!state.active || state.phase !== "waiting") return;
395
+
396
+ const previousRound = state.round;
397
+ state.round += 1;
398
+ state.phase = "round";
399
+ state.hasRoundContent = false;
400
+ if (!newRun()) {
401
+ state.round = previousRound;
402
+ state.phase = "waiting";
403
+ ctx.ui.notify("debug-mode: could not initialize the next run log file; staying at the reproduction gate", "error");
404
+ refreshUi();
405
+ return;
396
406
  }
407
+ refreshUi();
408
+
409
+ const userEvidence = reproductionDetails
410
+ ? `User added reproduction details after run ${run}:\n\n${reproductionDetails}\n\nTreat these details as evidence alongside the captured logs.\n`
411
+ : `User chose PROCEED — the fix did not resolve it (run ${run} captured ${logCount} log entries).\n`;
412
+ pi.sendMessage(
413
+ {
414
+ customType: reproductionDetails ? "debug-mode-note" : "debug-mode-proceed",
415
+ content:
416
+ userEvidence +
417
+ (logCount === 0
418
+ ? "No logs were captured: the instrumented code path may not have executed, the build may be stale, the path may be wrong, or the file append may have failed — treat that as a signal.\n"
419
+ : `Run ${run} captured ${logCount} log entries.\n`) +
420
+ "Read the previous run with get_debug_logs, update the hypotheses, re-instrument, attempt the next fix, give reproduction steps, and stop.",
421
+ display: true,
422
+ },
423
+ { triggerTurn: true },
424
+ );
397
425
  }
398
426
 
399
- async function teardown(ctx: ExtensionContext, keepFixes: boolean): Promise<void> {
427
+ async function teardown(ctx: ExtensionContext, outcome: "finished" | "aborted"): Promise<void> {
400
428
  if (state.debugDir) {
401
429
  try {
402
430
  fs.rmSync(state.debugDir, { recursive: true, force: true });
@@ -404,17 +432,67 @@ export default function debugModeExtension(pi: ExtensionAPI) {
404
432
  pi.logger.warn("debug-mode: failed to remove debug dir", { err });
405
433
  }
406
434
  }
407
- stopServer();
408
435
  const probesLeft = await checkLedger();
409
436
  Object.assign(state, freshState());
437
+ pi.appendEntry(DEBUG_ENTRY, { ...state });
410
438
  refreshUi();
439
+ const resultLabel = outcome === "finished" ? "finished" : "aborted";
411
440
  if (probesLeft.length > 0) {
412
441
  ctx.ui.notify(
413
- `debug-mode ended, but ${probesLeft.length} probe(s) remain in code: ${probesLeft.map(p => p.id).join(", ")} — remove manually.`,
442
+ `Debug mode ${resultLabel}, but ${probesLeft.length} probe(s) remain in code: ${probesLeft.map(p => p.id).join(", ")} — remove manually. Applied fixes remain in the working diff.`,
414
443
  "warning",
415
444
  );
416
- } else if (keepFixes) {
417
- ctx.ui.notify("Debug mode finished. Log files removed; working diff contains the fix — review with git diff.", "info");
445
+ } else {
446
+ ctx.ui.notify(
447
+ `Debug mode ${resultLabel}. Log files removed; applied fixes remain in the working diff for review.`,
448
+ "info",
449
+ );
450
+ }
451
+ }
452
+
453
+ async function abortDebug(ctx: ExtensionContext): Promise<void> {
454
+ if (!state.active) {
455
+ ctx.ui.notify("debug-mode: not active", "error");
456
+ return;
457
+ }
458
+ if (ctx.hasUI) {
459
+ const confirmed = await ctx.ui.confirm(
460
+ "Abort debug mode?",
461
+ "Delete captured debug logs and stop the workflow? Applied code changes will remain in the working diff.",
462
+ );
463
+ if (!confirmed) return;
464
+ }
465
+ if (!state.active) return;
466
+ await teardown(ctx, "aborted");
467
+ }
468
+
469
+ async function openReviewMenu(ctx: ExtensionContext): Promise<void> {
470
+ if (!ensureWaiting(ctx)) return;
471
+ if (!ctx.hasUI) {
472
+ ctx.ui.notify("/debug-review requires an interactive UI; use /debug-done fixed|proceed instead.", "warning");
473
+ return;
474
+ }
475
+ if (reviewMenuOpen) {
476
+ ctx.ui.notify("debug-mode: review menu is already open", "info");
477
+ return;
478
+ }
479
+
480
+ reviewMenuOpen = true;
481
+ try {
482
+ const choice = await ctx.ui.select(`Review debug round ${state.round}`, [...REVIEW_OPTIONS]);
483
+ if (!choice) return;
484
+ if (choice === REVIEW_MARK_FIXED) {
485
+ await markDebugFixed(ctx);
486
+ } else if (choice === REVIEW_PROCEED) {
487
+ await advanceDebug(ctx);
488
+ } else if (choice === REVIEW_ADD_DETAILS) {
489
+ ctx.ui.setEditorText("/debug-note ");
490
+ ctx.ui.notify("Add reproduction details in the editor, then submit /debug-note.", "info");
491
+ } else if (choice === REVIEW_ABORT) {
492
+ await abortDebug(ctx);
493
+ }
494
+ } finally {
495
+ reviewMenuOpen = false;
418
496
  }
419
497
  }
420
498
 
@@ -426,11 +504,34 @@ export default function debugModeExtension(pi: ExtensionAPI) {
426
504
  handler: async (args, ctx) => {
427
505
  uiCtx = ctx;
428
506
  const verdict = args.trim().toLowerCase();
429
- if (verdict !== "fixed" && verdict !== "proceed") {
507
+ if (verdict === "fixed") {
508
+ await markDebugFixed(ctx);
509
+ } else if (verdict === "proceed") {
510
+ await advanceDebug(ctx);
511
+ } else {
430
512
  ctx.ui.notify("Usage: /debug-done fixed|proceed", "error");
513
+ }
514
+ },
515
+ });
516
+
517
+ pi.registerCommand("debug-review", {
518
+ description: "Open the interactive action menu for a completed debug round",
519
+ handler: async (_args, ctx) => {
520
+ uiCtx = ctx;
521
+ await openReviewMenu(ctx);
522
+ },
523
+ });
524
+
525
+ pi.registerCommand("debug-note", {
526
+ description: "Add reproduction details and continue: /debug-note <details>",
527
+ handler: async (args, ctx) => {
528
+ uiCtx = ctx;
529
+ const details = args.trim();
530
+ if (!details) {
531
+ ctx.ui.notify("Usage: /debug-note <reproduction details>", "error");
431
532
  return;
432
533
  }
433
- finishDebug(ctx, verdict);
534
+ await advanceDebug(ctx, details);
434
535
  },
435
536
  });
436
537
 
@@ -438,20 +539,7 @@ export default function debugModeExtension(pi: ExtensionAPI) {
438
539
  description: "Abort debug mode: delete logs (fixes stay in the working diff)",
439
540
  handler: async (_args, ctx) => {
440
541
  uiCtx = ctx;
441
- if (!state.active) {
442
- ctx.ui.notify("debug-mode: not active", "error");
443
- return;
444
- }
445
- const ledger = await checkLedger();
446
- await teardown(ctx, true);
447
- if (ledger.length > 0) {
448
- ctx.ui.notify(
449
- `Debug mode aborted. These probes remain (remove manually or ask the agent):\n${ledger.map(p => ` ${p.id} — ${p.file}`).join("\n")}\nApplied fixes are kept in the working diff.`,
450
- "warning",
451
- );
452
- } else {
453
- ctx.ui.notify("Debug mode aborted. No probes in code; applied fixes are kept in the working diff.", "info");
454
- }
542
+ await abortDebug(ctx);
455
543
  },
456
544
  });
457
545
 
@@ -463,12 +551,14 @@ export default function debugModeExtension(pi: ExtensionAPI) {
463
551
  ctx.ui.notify("debug-mode: idle", "info");
464
552
  return;
465
553
  }
554
+ refreshLogCounts();
466
555
  const alive = await checkLedger();
467
556
  ctx.ui.notify(
468
557
  `debug-mode: phase=${state.phase} round=${state.round} run=${state.runId}\n` +
469
558
  `probes (ledger ${state.probes.length}, alive ${alive.length}):\n` +
470
559
  (alive.map(p => ` ${p.id} — ${p.file}`).join("\n") || " (none)") +
471
- `\nlogs: ${Object.entries(state.logCounts).map(([r, n]) => `${r}=${n}`).join(", ") || "(none)"}`,
560
+ `\nlogs: ${Object.entries(state.logCounts).map(([r, n]) => `${r}=${n}`).join(", ") || "(none)"}` +
561
+ `\ncurrent log file: ${logFileFor(state) ?? "(not initialized)"}`,
472
562
  "info",
473
563
  );
474
564
  },
@@ -476,11 +566,12 @@ export default function debugModeExtension(pi: ExtensionAPI) {
476
566
 
477
567
  // ============================== tools ==============================
478
568
 
569
+
479
570
  pi.registerTool({
480
571
  name: "get_debug_logs",
481
572
  label: "Get Debug Logs",
482
573
  description:
483
- "Read logs captured by debug-mode probes. Each entry: {run, probe, ts, data}. Returns JSONL text. Call with previous=true to analyze the last completed reproduction run.",
574
+ "Read JSONL observations appended directly by debug-mode runtime probes. Each entry: {probe, ts, data}. Call with previous=true to analyze the last completed reproduction run.",
484
575
  parameters: z.object({
485
576
  run: z.string().optional().describe("Run id filter (default: current run)"),
486
577
  probe: z.string().optional().describe("Probe id filter"),
@@ -488,33 +579,42 @@ export default function debugModeExtension(pi: ExtensionAPI) {
488
579
  }),
489
580
  approval: "read",
490
581
  async execute(_toolCallId, params) {
582
+ refreshLogCounts();
491
583
  const runs = Object.keys(state.logCounts);
492
584
  let run = params.run;
493
- if (!run && params.previous) run = runs[runs.length - 2];
494
- if (!run) run = state.runId ?? undefined;
495
- // disk is authoritative (survives resume); memory buffer is fallback
496
- let text = "";
497
- if (state.debugDir && run) {
498
- try {
499
- text = await Bun.file(path.join(state.debugDir, `${run}.jsonl`)).text();
500
- } catch {
501
- text = "";
585
+ if (!run && params.previous) {
586
+ run = runs[runs.length - 2];
587
+ if (!run) {
588
+ return {
589
+ content: [{ type: "text", text: "(no completed previous debug run is available)" }],
590
+ details: { run: null, file: null, count: 0 },
591
+ };
502
592
  }
503
593
  }
504
- if (!text) {
505
- text = logBuffer
506
- .filter(e => (!run || e.run === run) && (!params.probe || e.probe === params.probe))
507
- .map(e => JSON.stringify(e))
508
- .join("\n");
594
+ if (!run) run = state.runId ?? undefined;
595
+
596
+ let lines = run ? readRunLines(run) : [];
597
+ if (params.probe) {
598
+ lines = lines.filter(line => {
599
+ try {
600
+ const entry = JSON.parse(line) as { probe?: unknown };
601
+ return entry.probe === params.probe;
602
+ } catch {
603
+ return false;
604
+ }
605
+ });
509
606
  }
607
+ const text = lines.join("\n");
510
608
  return {
511
609
  content: [
512
610
  {
513
611
  type: "text",
514
- text: text || "(no logs captured — probes did not report; wrong code path, stale build, or server unreachable)",
612
+ text:
613
+ text ||
614
+ "(no logs captured — the instrumented path may not have executed, the build may be stale, the path may be wrong, or the file append may have failed)",
515
615
  },
516
616
  ],
517
- details: { run: run ?? null, count: text.split("\n").filter(Boolean).length },
617
+ details: { run: run ?? null, file: run ? logFileFor(state, run) : null, count: lines.length },
518
618
  };
519
619
  },
520
620
  });
@@ -553,29 +653,22 @@ export default function debugModeExtension(pi: ExtensionAPI) {
553
653
  .pop() as { data?: DebugState } | undefined;
554
654
  if (last?.data?.active) {
555
655
  Object.assign(state, last.data);
556
- if (state.debugDir && startServer(ctx, DEFAULT_PORT)) {
557
- // replay disk logs for analysis continuity across resume
558
- if (fs.existsSync(state.debugDir)) {
559
- for (const f of fs.readdirSync(state.debugDir)) {
560
- if (!f.endsWith(".jsonl")) continue;
561
- const run = f.replace(/\.jsonl$/, "");
562
- try {
563
- const lines = (await Bun.file(path.join(state.debugDir!, f)).text()).split("\n").filter(Boolean);
564
- state.logCounts[run] = lines.length;
565
- for (const l of lines) {
566
- try {
567
- logBuffer.push(JSON.parse(l) as LogEntry);
568
- } catch {}
569
- }
570
- } catch {}
656
+ if (!state.debugDir || !fs.existsSync(state.debugDir)) initializeLogDirectory(ctx);
657
+ const currentFile = logFileFor(state);
658
+ if (currentFile) {
659
+ try {
660
+ const legacyRunFile = state.debugDir && state.runId ? path.join(state.debugDir, `${state.runId}.jsonl`) : null;
661
+ if (!fs.existsSync(currentFile) && legacyRunFile && fs.existsSync(legacyRunFile)) {
662
+ fs.renameSync(legacyRunFile, currentFile);
571
663
  }
572
- }
573
- if (state.runId) {
574
- pendingWrite = fs.createWriteStream(path.join(state.debugDir, `${state.runId}.jsonl`), { flags: "a" });
664
+ fs.writeFileSync(currentFile, "", { flag: "a" });
665
+ } catch (err) {
666
+ pi.logger.error("debug-mode: cannot restore run log", { file: currentFile, err });
575
667
  }
576
668
  }
669
+ refreshLogCounts();
577
670
  ctx.ui.notify(
578
- `debug-mode resumed: phase=${state.phase} round=${state.round}. Use /debug-status, /debug-done fixed|proceed, or /debug-abort.`,
671
+ `debug-mode resumed: phase=${state.phase} round=${state.round}, log=${currentFile ?? "unavailable"}. Use /debug-status, /debug-review, /debug-done fixed|proceed, or /debug-abort.`,
579
672
  "info",
580
673
  );
581
674
  }
@@ -586,9 +679,6 @@ export default function debugModeExtension(pi: ExtensionAPI) {
586
679
  if (state.active) pi.appendEntry(DEBUG_ENTRY, { ...state });
587
680
  });
588
681
 
589
- pi.on("session_shutdown", async () => {
590
- stopServer();
591
- });
592
682
 
593
683
  pi.setLabel("Debug Mode");
594
684
  }
@@ -0,0 +1,11 @@
1
+ export const REVIEW_MARK_FIXED = "Mark as fixed";
2
+ export const REVIEW_PROCEED = "Proceed with captured logs";
3
+ export const REVIEW_ADD_DETAILS = "Add reproduction details";
4
+ export const REVIEW_ABORT = "Abort debug mode";
5
+
6
+ export const REVIEW_OPTIONS = [
7
+ REVIEW_MARK_FIXED,
8
+ REVIEW_PROCEED,
9
+ REVIEW_ADD_DETAILS,
10
+ REVIEW_ABORT,
11
+ ] as const;