@siuver/omp-debug-mode 0.1.4 → 0.1.6

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/src/debug-mode.ts CHANGED
@@ -1,9 +1,21 @@
1
1
  import { Text } from "@oh-my-pi/pi-coding-agent";
2
- import type { ExtensionAPI, ExtensionContext, MessageRenderer } from "@oh-my-pi/pi-coding-agent";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionCommandContext,
5
+ ExtensionContext,
6
+ MessageRenderer,
7
+ } from "@oh-my-pi/pi-coding-agent";
3
8
  import * as fs from "node:fs";
4
9
  import * as path from "node:path";
5
- import { addEvidenceArtifact, describeEvidence, parseEvidencePlan } from "./evidence";
6
- import { decideGate } from "./gate";
10
+ import {
11
+ type EvidenceCompletion,
12
+ UNLINKED_SELECTOR,
13
+ describeEvidence,
14
+ evidenceCompletions,
15
+ parseEvidenceArgument,
16
+ validateEvidenceArtifact,
17
+ } from "./evidence";
18
+ import { describeHandoff } from "./gate";
7
19
  import {
8
20
  ACTIVE_LOG_FILE,
9
21
  JsonlLineCounter,
@@ -12,32 +24,28 @@ import {
12
24
  readJsonlLines,
13
25
  summarizeHypotheses,
14
26
  } from "./log-files";
15
- import { describeLedger, recordProbes, syncLedger } from "./probes";
16
- import {
17
- CLEANUP_CONTRACT,
18
- METHODOLOGY,
19
- PROCEED_REMINDER,
20
- buildFixedMessage,
21
- buildProceedMessage,
22
- buildStartMessage,
23
- extractAssistantText,
24
- extractReproductionSteps,
25
- } from "./methodology";
27
+ import { type DebugEvent, type Effect, PROMPT_FIXED, PROMPT_PROCEED, PROMPT_START, reduce } from "./machine";
28
+ import { CLEANUP_CONTRACT, METHODOLOGY, extractAssistantText } from "./methodology";
29
+ import { type LedgerScan, describeLedger, probesInInput, scanLedger, survivingProbes } from "./probes";
26
30
  import {
31
+ type DebugSession,
32
+ type DebugState,
27
33
  DEBUG_CONTEXT_TYPE,
28
34
  DEBUG_ENTRY,
29
- type DebugState,
30
- type EvidenceObservation,
35
+ INACTIVE,
36
+ activeRunId,
31
37
  blackboard,
32
38
  compareRunIds,
39
+ currentRound,
33
40
  evidenceSummary,
34
- freshState,
35
- keepLatestCustomType,
41
+ evidenceView,
42
+ hasNonProbeEvidence,
36
43
  logFileFor,
37
- pendingEvidenceRequests,
38
- replaceRoundEvidenceRequests,
44
+ pendingRequests,
45
+ reviveState,
46
+ syncCustomType,
39
47
  } from "./state";
40
- import { registerDebugTools } from "./tools";
48
+ import { type HandoffOutcome, type HandoffRequest, nextActiveTools, registerDebugTools } from "./tools";
41
49
  import { applyUi } from "./ui";
42
50
  import { debugDirFor, excludeDebugLogsFromGit, pruneDebugRoot } from "./workspace";
43
51
 
@@ -50,17 +58,27 @@ const COMMAND_STATUS = "debug-status";
50
58
 
51
59
  /** Compact transcript lines for the prompts this extension injects. */
52
60
  const MESSAGE_SUMMARIES: Record<string, string> = {
53
- "debug-mode-start": "debug mode started — hypotheses and instrumentation",
54
- "debug-mode-proceed": "proceed — analyzing captured logs",
55
- "debug-mode-fixed": "marked fixed — removing probes and summarizing",
61
+ [PROMPT_START]: "debug mode started — hypotheses and instrumentation",
62
+ [PROMPT_PROCEED]: "proceed — analyzing captured logs",
63
+ [PROMPT_FIXED]: "marked fixed — removing probes and summarizing",
56
64
  };
57
65
 
58
66
  interface DebugMessageDetails {
59
67
  summary?: string;
60
68
  }
61
69
 
70
+ /**
71
+ * Argument completion reached the host's command options after the version this
72
+ * package type-checks against, hence the spread instead of an inline key.
73
+ */
74
+ function argumentCompletions(provide: (argumentPrefix: string) => EvidenceCompletion[] | null): {
75
+ getArgumentCompletions?: (argumentPrefix: string) => EvidenceCompletion[] | null;
76
+ } {
77
+ return { getArgumentCompletions: provide };
78
+ }
79
+
62
80
  export function registerDebugMode(pi: ExtensionAPI): void {
63
- const state: DebugState = freshState();
81
+ let state: DebugState = INACTIVE;
64
82
  let uiCtx: ExtensionContext | null = null;
65
83
  let watchedLogFile: string | null = null;
66
84
  const lineCounter = new JsonlLineCounter();
@@ -73,79 +91,164 @@ export function registerDebugMode(pi: ExtensionAPI): void {
73
91
  pi.registerMessageRenderer<DebugMessageDetails>(customType, renderer);
74
92
  }
75
93
 
76
- // ============================== log files ==============================
94
+ // ============================== state plumbing ==============================
77
95
 
78
- function initializeLogDirectory(ctx: ExtensionContext): boolean {
79
- const dir = debugDirFor(ctx.cwd, ctx.sessionManager.getSessionId());
80
- try {
81
- fs.mkdirSync(dir, { recursive: true });
82
- state.debugDir = dir;
83
- excludeDebugLogsFromGit(ctx.cwd);
84
- return true;
85
- } catch (err) {
86
- pi.logger.error("debug-mode: cannot create log dir", { dir, err });
87
- state.debugDir = null;
88
- return false;
96
+ /**
97
+ * Effect-free bookkeeping: these events carry no effects, so they may run
98
+ * inside a render pass without re-entering the UI refresh that asked for
99
+ * them, and they are deliberately not persisted — every one of them is
100
+ * either a disk observation or live-turn state that a restore rebuilds.
101
+ */
102
+ function absorbCache(event: Extract<DebugEvent, { t: "runs_observed" | "ledger_synced" | "tool_used" }>): void {
103
+ state = reduce(state, event).state;
104
+ }
105
+
106
+ /** Run one event through the machine, then realise whatever it asked for. */
107
+ function dispatch(event: DebugEvent, ctx: ExtensionContext): string | null {
108
+ const transition = reduce(state, event);
109
+ // The reducer returns the previous object for a genuine no-op, so a turn
110
+ // full of tool calls does not append a state snapshot per message.
111
+ const changed = transition.state !== state;
112
+ state = transition.state;
113
+ if (changed) pi.appendEntry(DEBUG_ENTRY, { ...state });
114
+ refreshUi();
115
+ watchLogFile();
116
+ let continueContext: string | null = null;
117
+ for (const effect of transition.effects) {
118
+ const context = applyEffect(effect, ctx);
119
+ if (context) continueContext = context;
89
120
  }
121
+ return continueContext;
90
122
  }
91
123
 
124
+ function applyEffect(effect: Effect, ctx: ExtensionContext): string | null {
125
+ switch (effect.kind) {
126
+ case "notify":
127
+ ctx.ui.notify(effect.text, effect.level);
128
+ return null;
129
+ case "prompt":
130
+ pi.sendMessage(
131
+ {
132
+ customType: effect.customType,
133
+ content: effect.content,
134
+ display: true,
135
+ details: { summary: effect.summary } satisfies DebugMessageDetails,
136
+ },
137
+ { triggerTurn: true },
138
+ );
139
+ return null;
140
+ case "continue":
141
+ return effect.context;
142
+ case "teardown": {
143
+ unwatchLogFile();
144
+ lineCounter.clear();
145
+ if (effect.debugDir) {
146
+ try {
147
+ fs.rmSync(effect.debugDir, { recursive: true, force: true });
148
+ pruneDebugRoot(ctx.cwd);
149
+ } catch (err) {
150
+ pi.logger.warn("debug-mode: failed to remove debug dir", { err });
151
+ }
152
+ }
153
+ const label = effect.outcome === "finished" ? "finished" : "aborted";
154
+ if (effect.probesLeft.length > 0) {
155
+ ctx.ui.notify(
156
+ `Debug mode ${label}, but ${effect.probesLeft.length} probe(s) remain in code: ${effect.probesLeft.map(p => p.id).join(", ")} — remove manually. Applied fixes remain in the working diff.`,
157
+ "warning",
158
+ );
159
+ } else {
160
+ ctx.ui.notify(
161
+ `Debug mode ${label}. Log files removed; applied fixes remain in the working diff for review.`,
162
+ "info",
163
+ );
164
+ }
165
+ void setDebugToolsActive(false);
166
+ return null;
167
+ }
168
+ }
169
+ }
170
+
171
+ function refreshUi(): void {
172
+ applyUi(uiCtx, state, currentLogCount);
173
+ }
174
+
175
+ // ============================== log files ==============================
176
+
92
177
  function readRunLines(run: string): string[] {
93
- return readJsonlLines(logFileFor(state, run));
178
+ return state.active ? readJsonlLines(logFileFor(state, run)) : [];
94
179
  }
95
180
 
181
+ /** Re-count every run on disk. Pure cache maintenance — never a decision. */
96
182
  function refreshLogCounts(): void {
97
- const runs = new Set(Object.keys(state.logCounts));
98
- if (state.debugDir) {
183
+ if (!state.active) return;
184
+ const session: DebugSession = state;
185
+ const active = activeRunId(session);
186
+ const runs = new Set(Object.keys(session.logCounts));
187
+ if (session.debugDir) {
99
188
  try {
100
- for (const file of fs.readdirSync(state.debugDir)) {
189
+ for (const file of fs.readdirSync(session.debugDir)) {
101
190
  if (!file.endsWith(".jsonl")) continue;
102
191
  if (file === ACTIVE_LOG_FILE) {
103
- if (state.runId) runs.add(state.runId);
192
+ if (active) runs.add(active);
104
193
  } else {
105
194
  runs.add(file.replace(/\.jsonl$/, ""));
106
195
  }
107
196
  }
108
197
  } catch {}
109
198
  }
110
- for (const run of runs) state.logCounts[run] = lineCounter.count(logFileFor(state, run));
111
- adoptDiscoveredRuns(runs);
199
+ const logCounts: Record<string, number> = {};
200
+ for (const run of runs) logCounts[run] = lineCounter.count(logFileFor(session, run));
201
+ absorbCache({ t: "runs_observed", runHistory: orderedHistory(session, runs), logCounts });
112
202
  }
113
203
 
114
204
  /** Keep the ordered history complete when runs are discovered from disk. */
115
- function adoptDiscoveredRuns(runs: Iterable<string>): void {
116
- const missing = [...runs].filter(run => run !== state.runId && !state.runHistory.includes(run));
117
- if (missing.length === 0) return;
118
- const active = state.runId && state.runHistory.includes(state.runId) ? state.runId : null;
119
- const completed = state.runHistory.filter(run => run !== active).concat(missing);
205
+ function orderedHistory(session: DebugSession, runs: Iterable<string>): string[] {
206
+ const active = activeRunId(session);
207
+ const missing = [...runs].filter(run => run !== active && !session.runHistory.includes(run));
208
+ if (missing.length === 0) return session.runHistory;
209
+ const completed = session.runHistory.filter(run => run !== active).concat(missing);
120
210
  completed.sort(compareRunIds);
121
- state.runHistory = active ? [...completed, active] : completed;
211
+ return active ? [...completed, active] : completed;
122
212
  }
123
213
 
124
214
  function currentLogCount(): number {
125
215
  refreshLogCounts();
126
- return state.runId ? (state.logCounts[state.runId] ?? 0) : 0;
216
+ if (!state.active) return 0;
217
+ const run = activeRunId(state);
218
+ return run ? (state.logCounts[run] ?? 0) : 0;
219
+ }
220
+
221
+ function fsErrorMessage(error: unknown): string {
222
+ if (error instanceof Error) {
223
+ const code = "code" in error && typeof error.code === "string" ? error.code : "";
224
+ return code ? `${code}: ${error.message}` : error.message;
225
+ }
226
+ return String(error);
127
227
  }
128
228
 
129
- function newRun(): string | null {
130
- if (!state.debugDir) return null;
131
- const run = `run${state.round}-${Date.now().toString(36)}`;
229
+ function runLogHint(error: string): string {
230
+ if (/\b(EPERM|EBUSY|EACCES|EAGAIN)\b/.test(error)) {
231
+ return " Close the instrumented app if it still has the log file open, then retry.";
232
+ }
233
+ return "";
234
+ }
235
+
236
+ /** Archive the active log, truncate it for the next reproduction, and name the run. */
237
+ function createRun(
238
+ debugDir: string,
239
+ round: number,
240
+ previousRun: string | null,
241
+ ): { runId: string } | { error: string } {
132
242
  try {
133
- prepareRunLog(state.debugDir, state.runId);
243
+ prepareRunLog(debugDir, previousRun);
134
244
  } catch (err) {
135
245
  pi.logger.error("debug-mode: cannot initialize run log", {
136
- file: path.join(state.debugDir, ACTIVE_LOG_FILE),
246
+ file: path.join(debugDir, ACTIVE_LOG_FILE),
137
247
  err,
138
248
  });
139
- return null;
249
+ return { error: fsErrorMessage(err) };
140
250
  }
141
- state.runId = run;
142
- state.runHistory.push(run);
143
- state.logCounts[run] = 0;
144
- return run;
145
- }
146
-
147
- function refreshUi(): void {
148
- applyUi(uiCtx, state, currentLogCount);
251
+ return { runId: `run${round}-${Date.now().toString(36)}` };
149
252
  }
150
253
 
151
254
  /**
@@ -154,13 +257,13 @@ export function registerDebugMode(pi: ExtensionAPI): void {
154
257
  * refresh the widget as observations land.
155
258
  */
156
259
  function watchLogFile(): void {
157
- const file = state.phase === "waiting" ? logFileFor(state) : null;
260
+ const file = state.active && state.stage === "user_turn" ? logFileFor(state) : null;
158
261
  if (file === watchedLogFile) return;
159
262
  unwatchLogFile();
160
263
  if (!file || !uiCtx?.hasUI) return;
161
264
  try {
162
265
  fs.watchFile(file, { interval: 1000 }, (curr, prev) => {
163
- if (state.phase !== "waiting") return;
266
+ if (!state.active || state.stage !== "user_turn") return;
164
267
  if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return;
165
268
  try {
166
269
  refreshUi();
@@ -186,336 +289,292 @@ export function registerDebugMode(pi: ExtensionAPI): void {
186
289
 
187
290
  // ============================== probe ledger ==============================
188
291
 
292
+ /** Rescan the recorded files so the ledger matches the code on disk. */
293
+ async function syncLedger(): Promise<LedgerScan> {
294
+ const scan = await scanLedger(state.active ? state.probes : []);
295
+ if (state.active) absorbCache({ t: "ledger_synced", probes: survivingProbes(scan) });
296
+ return scan;
297
+ }
298
+
189
299
  pi.on("tool_call", async (event, ctx) => {
190
300
  if (!state.active) return;
301
+ // Any tool call clears the reminder guard: the agent acted on the last
302
+ // reminder, so a further one is worth spending.
303
+ absorbCache({ t: "tool_used" });
191
304
  if (event.toolName !== "edit" && event.toolName !== "write") return;
192
- recordProbes(state.probes, state.round, event.input as Record<string, unknown>, ctx.cwd);
305
+ const probes = probesInInput(event.input as Record<string, unknown>, ctx.cwd, currentRound(state).index);
306
+ if (probes.length > 0) dispatch({ t: "probes_found", probes }, ctx);
193
307
  });
194
308
 
195
309
  // ============================== prompt injection ==============================
196
310
 
197
- pi.on("before_agent_start", async () => {
198
- if (!state.active || (state.phase !== "round" && state.phase !== "cleanup")) return;
199
- // The blackboard claims to be ground truth, so reconcile it with disk first.
200
- await syncLedger(state);
311
+ /** The blackboard plus the contract for the stage the session is in now. */
312
+ function injectedContext(session: DebugSession): string {
201
313
  // Cleanup has no hypotheses left to form; the full methodology would only
202
314
  // invite another round.
203
- const contract = state.phase === "cleanup" ? CLEANUP_CONTRACT : METHODOLOGY;
315
+ const contract = session.stage === "cleaning_up" ? CLEANUP_CONTRACT : METHODOLOGY;
316
+ return `${blackboard(session, describeEvidence(evidenceView(session)))}\n\n${contract}`;
317
+ }
318
+
319
+ // Injected on every prompt, including a reply on the user's turn. Skipping the
320
+ // injection there is what left the model with no idea which stage it was in,
321
+ // and a model that cannot see the state cannot be blamed for misjudging it.
322
+ //
323
+ // The attribution is explicit because the host otherwise inherits it from the
324
+ // prompt being answered, which stamps this extension's own blackboard as
325
+ // user-authored. Models read that literally and report the stage back as
326
+ // something the user said.
327
+ pi.on("before_agent_start", async () => {
328
+ if (!state.active) return;
329
+ // The blackboard claims to be ground truth, so reconcile it with disk first.
330
+ await syncLedger();
331
+ if (!state.active) return;
204
332
  return {
205
333
  message: {
206
334
  customType: DEBUG_CONTEXT_TYPE,
207
- content: `${blackboard(state, describeEvidence(state))}\n\n${contract}`,
208
- display: false,
335
+ content: injectedContext(state),
336
+ display: false,
337
+ attribution: "agent",
209
338
  },
210
339
  };
211
340
  });
212
341
 
213
- // Drop stale blackboard copies. Keep the newest `context` runs after
214
- // `before_agent_start`, so deleting every match would hide the injection
215
- // from the model.
342
+ // Keep exactly one, current blackboard in the request. `before_agent_start`
343
+ // only fires for a submitted prompt, so a turn the host resumed by itself —
344
+ // a todo reminder, a plan nudge, a queued-message drain — would otherwise
345
+ // hand the model the previous turn's stage. This hook runs on every provider
346
+ // request, so re-rendering here is what makes the stage unskippable.
216
347
  pi.on("context", async (event) => {
217
- const filtered = keepLatestCustomType(event.messages, DEBUG_CONTEXT_TYPE);
218
- if (filtered.length !== event.messages.length) return { messages: filtered };
348
+ const messages = syncCustomType(
349
+ event.messages,
350
+ DEBUG_CONTEXT_TYPE,
351
+ state.active ? injectedContext(state) : null,
352
+ );
353
+ if (messages) return { messages };
219
354
  });
220
355
 
221
- // ============================== round lifecycle ==============================
356
+ // ============================== turn lifecycle ==============================
222
357
 
223
- pi.on("agent_start", async () => {
224
- if (state.active) state.hasRoundContent = false;
358
+ pi.on("agent_start", async (_event, ctx) => {
359
+ if (state.active) dispatch({ t: "turn_started" }, ctx);
225
360
  });
226
361
 
227
- pi.on("message_end", async (event) => {
362
+ pi.on("message_end", async (event, ctx) => {
228
363
  if (!state.active) return;
229
364
  const msg = event.message as { role?: string; content?: unknown };
230
- if (msg?.role === "assistant") {
231
- if (state.phase === "cleanup") state.cleanupReady = true;
232
- state.hasRoundContent = true;
233
- const text = extractAssistantText(msg.content);
234
- const steps = extractReproductionSteps(text);
235
- if (steps.length > 0) state.reproductionSteps = steps;
236
- if (state.phase === "round") {
237
- const plan = parseEvidencePlan(text, state.round);
238
- if (plan.found) {
239
- replaceRoundEvidenceRequests(state, state.round, plan.valid ? plan.requests : []);
240
- pi.appendEntry(DEBUG_ENTRY, { ...state });
241
- }
242
- }
365
+ // A user message is the one thing a host continuation cannot fake: its
366
+ // reminders are `developer` messages. So this, not the start of a turn,
367
+ // is what hands a round back to the agent.
368
+ if (msg?.role === "user") {
369
+ dispatch({ t: "user_replied" }, ctx);
370
+ return;
243
371
  }
372
+ if (msg?.role !== "assistant") return;
373
+ dispatch({ t: "assistant_message", text: extractAssistantText(msg.content) }, ctx);
244
374
  });
245
375
 
246
376
  pi.on("session_stop", async (_event, ctx) => {
247
- if (!state.active) return;
248
- if (state.phase === "cleanup") {
249
- if (state.cleanupReady) await teardown(ctx, "finished");
250
- return;
251
- }
252
- // Ordinary editor messages while waiting are conversational only. Only an
253
- // explicit debug command advances the run or leaves the gate.
254
- if (state.phase !== "round" || !state.hasRoundContent) return;
255
- const probesThisRound = state.probes.filter(p => p.round === state.round).length;
256
- const hasEvidencePlan = state.evidenceRequests.some(request => request.round === state.round);
257
- const decision = decideGate({
258
- hasReproductionSteps: state.reproductionSteps.length > 0,
259
- hasEvidencePlan,
260
- probesThisRound,
261
- nudgesUsed: state.gateNudges,
262
- });
263
- if (decision.kind === "stay") return;
264
- if (decision.kind === "nudge") {
265
- state.gateNudges += 1;
266
- return { continue: true, additionalContext: decision.context };
267
- }
268
- enterGate(ctx, probesThisRound, decision.missingSteps, decision.missingEvidencePlan);
377
+ if (!state.active || !state.turnProduced) return;
378
+ if (state.stage !== "investigating" && state.stage !== "cleaning_up") return;
379
+ // A declared runtime_probe must be backed by a marker that is really on
380
+ // disk, so the ledger is reconciled before the round may close.
381
+ await syncLedger();
382
+ const context = dispatch({ t: "turn_settled" }, ctx);
383
+ await setDebugToolsActive(state.active);
384
+ if (context) return { continue: true, additionalContext: context };
269
385
  });
270
386
 
271
- function enterGate(
272
- ctx: ExtensionContext,
273
- probesThisRound: number,
274
- missingSteps: boolean,
275
- missingEvidencePlan: boolean,
276
- ): void {
277
- state.phase = "waiting";
278
- pi.appendEntry(DEBUG_ENTRY, { ...state });
279
- refreshUi();
280
- watchLogFile();
281
- // Commands are the only interaction at the gate: the widget lists them,
282
- // the user picks one. No menu is opened automatically.
283
- const pending = pendingEvidenceRequests(state, state.round);
284
- const hasValidPlan = state.evidenceRequests.some(request => request.round === state.round);
285
- if (probesThisRound === 0 && !hasValidPlan) {
286
- ctx.ui.notify(
287
- `Debug round ${state.round} paused, but it added no probes and declared no evidence plan — this round cannot produce runtime evidence. Use /${COMMAND_PROCEED} to ask for instrumentation.`,
288
- "warning",
289
- );
290
- } else if (missingSteps) {
291
- ctx.ui.notify(
292
- `Debug round ${state.round} paused without reproduction steps. Exercise the instrumented path, then ${PROCEED_REMINDER}`,
293
- "warning",
294
- );
295
- } else if (pending.length > 0) {
296
- ctx.ui.notify(
297
- `Debug round ${state.round} paused. User evidence requested (${pending.length} pending: ${pending.map(r => r.id).join(", ")}) — attach via /${COMMAND_EVIDENCE} <request-id> <path>, then ${PROCEED_REMINDER}`,
298
- "info",
299
- );
300
- } else if (missingEvidencePlan) {
301
- ctx.ui.notify(
302
- `Debug round ${state.round} paused. The gate is usable, but the next Proceed will ask the model to declare an evidence method.`,
303
- "info",
304
- );
305
- } else {
306
- ctx.ui.notify(`Debug round ${state.round} paused. Reproduce the bug, then ${PROCEED_REMINDER}`, "info");
387
+ /**
388
+ * Apply an explicit handoff from the Agent tool. The machine only accepts one
389
+ * while the agent actually holds the round, so a duplicated or late call is
390
+ * reported back to the model instead of silently rewriting the user's turn.
391
+ */
392
+ function applyHandoffRequest(request: HandoffRequest, ctx: ExtensionContext): HandoffOutcome {
393
+ if (!state.active) return { ok: false, error: "debug mode is not active." };
394
+ if (state.stage !== "investigating") {
395
+ return {
396
+ ok: false,
397
+ error: `round ${currentRound(state).index} is already with the user (stage: ${state.stage}); do not hand off twice.`,
398
+ };
399
+ }
400
+ const round = currentRound(state);
401
+ const requests = (request.plan ?? round.plan ?? []).length;
402
+ dispatch({ t: "handoff", mode: request.mode, steps: request.steps, plan: request.plan }, ctx);
403
+ if (!state.active) return { ok: false, error: "debug mode ended while handing off." };
404
+ return {
405
+ ok: true,
406
+ summary:
407
+ `Round ${round.index} is now with the user as "${request.mode}": ${request.steps.length} step(s) and ` +
408
+ `${requests} evidence request(s) are showing in their widget, and /debug-proceed is available. ` +
409
+ "Stop here — the user reproduces or replies out-of-band.",
410
+ };
307
411
  }
308
- }
309
412
 
310
- // ============================== round transitions ==============================
413
+ registerDebugTools(pi, {
414
+ getState: () => state,
415
+ refreshLogCounts,
416
+ readRunLines,
417
+ syncLedger,
418
+ handOff: applyHandoffRequest,
419
+ });
311
420
 
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
- }
317
- state.active = true;
318
- state.phase = "round";
319
- state.problem = problem;
320
- state.round = 1;
321
- state.probes = [];
322
- state.runHistory = [];
323
- state.logCounts = {};
324
- state.hasRoundContent = false;
325
- state.cleanupReady = false;
326
- state.reproductionSteps = [];
327
- state.gateNudges = 0;
328
- state.evidenceRequests = [];
329
- state.evidenceArtifacts = [];
330
- state.evidenceObservations = [];
331
- if (!newRun()) {
332
- Object.assign(state, freshState());
333
- ctx.ui.notify("debug-mode: could not initialize the run log file; debug mode was not started", "error");
334
- return;
335
- }
336
- refreshUi();
337
- const logFile = logFileFor(state);
338
- if (!logFile) {
339
- Object.assign(state, freshState());
340
- ctx.ui.notify("debug-mode: could not resolve the run log file; debug mode was not started", "error");
341
- return;
421
+ /**
422
+ * The four debug tools are registered at plugin load as `defaultInactive`, so
423
+ * they are not in the model's schema on an ordinary session. They join the
424
+ * active set when `/debug-mode` starts (or a resumed session is already in
425
+ * debug mode) and leave it on teardown. `setActiveTools` replaces the whole
426
+ * enabled list, so this only adds or removes our names.
427
+ */
428
+ async function setDebugToolsActive(wanted: boolean): Promise<void> {
429
+ const getActive = pi.getActiveTools?.bind(pi);
430
+ const setActive = pi.setActiveTools?.bind(pi);
431
+ if (!getActive || !setActive) return;
432
+ const next = nextActiveTools(getActive(), wanted);
433
+ if (!next) return;
434
+ try {
435
+ await setActive(next);
436
+ } catch (err) {
437
+ pi.logger.warn("debug-mode: cannot update the active tool set", { err, wanted });
342
438
  }
343
- pi.sendMessage(
344
- {
345
- customType: "debug-mode-start",
346
- content: buildStartMessage(problem, logFile),
347
- display: true,
348
- details: { summary: "debug mode started — hypotheses and instrumentation" } satisfies DebugMessageDetails,
349
- },
350
- { triggerTurn: true },
351
- );
352
439
  }
353
440
 
354
- function ensureWaiting(ctx: ExtensionContext): boolean {
441
+ // ============================== command guards ==============================
442
+
443
+ function activeSession(ctx: ExtensionContext): DebugSession | null {
355
444
  if (!state.active) {
356
445
  ctx.ui.notify("debug-mode: not active", "error");
357
- return false;
446
+ return null;
358
447
  }
359
- if (state.phase !== "waiting") {
360
- ctx.ui.notify(`debug-mode: not waiting for reproduction (phase: ${state.phase})`, "error");
361
- return false;
448
+ return state;
449
+ }
450
+
451
+ /**
452
+ * Wait out any agent turn, then make sure the round really is with the user.
453
+ *
454
+ * The turn is claimed rather than inspected. An aborted turn never reaches
455
+ * `session_stop`, so a round could sit in `investigating` for the rest of the
456
+ * session with every debug command refused; waiting for idle turns "the agent
457
+ * is working" into a fact instead of a guess, and anything still unsettled
458
+ * afterwards is reclassified on the spot.
459
+ */
460
+ async function ownTurn(ctx: ExtensionCommandContext): Promise<DebugSession | null> {
461
+ if (!activeSession(ctx)) return null;
462
+ await ctx.waitForIdle();
463
+ if (!state.active) return null;
464
+ if (state.stage === "cleaning_up") {
465
+ ctx.ui.notify(
466
+ `debug-mode: cleanup is still finishing — reply to the agent, or use /${COMMAND_ABORT}.`,
467
+ "error",
468
+ );
469
+ return null;
362
470
  }
363
- return true;
471
+ if (state.stage === "investigating") dispatch({ t: "reclaim" }, ctx);
472
+ return state.active && state.stage === "user_turn" ? state : null;
364
473
  }
365
474
 
366
- async function markDebugFixed(ctx: ExtensionContext): Promise<void> {
367
- if (!ensureWaiting(ctx)) return;
368
- const logCount = currentLogCount();
369
- const hasNonProbeEvidence = state.evidenceRequests.some(
370
- request => request.round === state.round && request.method !== "runtime_probe",
371
- );
372
- if (logCount === 0 && !hasNonProbeEvidence && ctx.hasUI) {
475
+ /**
476
+ * `ownTurn` plus a confirmation for the rounds where continuing is a judgment
477
+ * call: the agent either asked a question or never said what to capture.
478
+ */
479
+ async function takeTurn(ctx: ExtensionCommandContext, action: string): Promise<DebugSession | null> {
480
+ const session = await ownTurn(ctx);
481
+ if (!session) return null;
482
+ const round = currentRound(session);
483
+ const mode = round.handoff ?? "incomplete";
484
+ if ((mode === "incomplete" || mode === "question") && ctx.hasUI) {
373
485
  const confirmed = await ctx.ui.confirm(
374
- "Mark as fixed without runtime logs?",
375
- "No runtime observations were captured for this round. Mark the problem as fixed anyway?",
486
+ `${action} round ${round.index}?`,
487
+ `${describeHandoff(mode, round.index)} Continue with the evidence that already exists?`,
376
488
  );
377
- if (!confirmed) return;
489
+ if (!confirmed) return null;
378
490
  }
379
- if (!state.active || state.phase !== "waiting") return;
491
+ return state.active ? state : null;
492
+ }
380
493
 
381
- state.phase = "cleanup";
382
- state.cleanupReady = false;
383
- unwatchLogFile();
384
- refreshUi();
385
- const evidenceJson = JSON.stringify({
386
- requests: state.evidenceRequests,
387
- observations: state.evidenceObservations,
388
- artifacts: state.evidenceArtifacts,
389
- });
390
- pi.sendMessage(
391
- {
392
- customType: "debug-mode-fixed",
393
- content: buildFixedMessage(JSON.stringify(state.probes), evidenceJson),
394
- details: { summary: `marked fixed — removing ${state.probes.length} probe(s)` } satisfies DebugMessageDetails,
395
- },
396
- { triggerTurn: true },
397
- );
398
- }
494
+ // ============================== round transitions ==============================
495
+
496
+ async function startDebug(ctx: ExtensionContext, problem: string): Promise<void> {
497
+ const debugDir = debugDirFor(ctx.cwd, ctx.sessionManager.getSessionId());
498
+ try {
499
+ fs.mkdirSync(debugDir, { recursive: true });
500
+ excludeDebugLogsFromGit(ctx.cwd);
501
+ } catch (err) {
502
+ pi.logger.error("debug-mode: cannot create log dir", { dir: debugDir, err });
503
+ ctx.ui.notify("debug-mode: could not create the debug log directory; debug mode was not started", "error");
504
+ return;
505
+ }
506
+ const created = createRun(debugDir, 1, null);
507
+ if ("error" in created) {
508
+ ctx.ui.notify(
509
+ `debug-mode: could not initialize the run log file (${created.error}); debug mode was not started.${runLogHint(created.error)}`,
510
+ "error",
511
+ );
512
+ return;
513
+ }
514
+ const runId = created.runId;
515
+ // The start prompt fires a turn immediately, so the four tools must already
516
+ // be in the schema or round 1 cannot close with hand_off_to_user.
517
+ await setDebugToolsActive(true);
518
+ dispatch({ t: "start", problem, debugDir, runId, logFile: path.join(debugDir, ACTIVE_LOG_FILE) }, ctx);
519
+ }
399
520
 
400
- async function advanceDebug(ctx: ExtensionContext, userDetails?: string): Promise<void> {
401
- if (!ensureWaiting(ctx)) return;
521
+ async function advanceDebug(ctx: ExtensionCommandContext, userDetails?: string): Promise<void> {
522
+ if (!(await takeTurn(ctx, "/debug-proceed from"))) return;
402
523
  refreshLogCounts();
403
- const run = state.runId ?? "(none)";
404
- const logCount = state.logCounts[run] ?? 0;
405
- const hasNonProbeEvidence = state.evidenceRequests.some(
406
- request => request.round === state.round && request.method !== "runtime_probe",
407
- );
408
- if (logCount === 0 && !userDetails && !hasNonProbeEvidence && ctx.hasUI) {
524
+ if (!state.active) return;
525
+ const closingRun = activeRunId(state);
526
+ const logCount = closingRun ? (state.logCounts[closingRun] ?? 0) : 0;
527
+ if (logCount === 0 && !userDetails && !hasNonProbeEvidence(currentRound(state)) && ctx.hasUI) {
409
528
  const confirmed = await ctx.ui.confirm(
410
- "Proceed without runtime logs?",
529
+ "/debug-proceed without runtime logs?",
411
530
  "No runtime observations were captured for this round. Continue to log analysis anyway?",
412
531
  );
413
532
  if (!confirmed) return;
414
533
  }
415
- if (!state.active || state.phase !== "waiting") return;
416
-
417
- // Persist optional /debug-proceed details as a batched user observation
418
- // tied to every current-round user_report request.
419
- if (userDetails && userDetails.trim().length > 0) {
420
- const reportIds = state.evidenceRequests
421
- .filter(request => request.round === state.round && request.method === "user_report")
422
- .map(request => request.id);
423
- const observation: EvidenceObservation = {
424
- id: `observation-${Date.now().toString(36)}`,
425
- requestIds: reportIds,
426
- text: userDetails.trim(),
427
- round: state.round,
428
- addedAt: Date.now(),
429
- };
430
- state.evidenceObservations = [...state.evidenceObservations, observation];
431
- }
432
- const stillPending = pendingEvidenceRequests(state, state.round);
534
+ if (!state.active) return;
535
+ const stillPending = pendingRequests(state);
433
536
  if (stillPending.length > 0 && ctx.hasUI) {
434
537
  const confirmed = await ctx.ui.confirm(
435
- "Proceed without all requested evidence?",
538
+ "/debug-proceed without all requested evidence?",
436
539
  `${stillPending.length} evidence request(s) still pending (${stillPending.map(r => r.id).join(", ")}). Continue anyway?`,
437
540
  );
438
- if (!confirmed) {
439
- pi.appendEntry(DEBUG_ENTRY, { ...state });
440
- refreshUi();
441
- return;
442
- }
541
+ if (!confirmed) return;
443
542
  }
543
+ if (!state.active || state.stage !== "user_turn" || !state.debugDir) return;
444
544
 
445
- const hypotheses = describeHypotheses(summarizeHypotheses(readRunLines(run)));
446
- const previousRound = state.round;
447
- const previousSteps = state.reproductionSteps;
448
- state.round += 1;
449
- state.phase = "round";
450
- state.hasRoundContent = false;
451
- state.reproductionSteps = [];
452
- state.gateNudges = 0;
453
- if (!newRun()) {
454
- state.round = previousRound;
455
- state.phase = "waiting";
456
- state.reproductionSteps = previousSteps;
457
- ctx.ui.notify("debug-mode: could not initialize the next run log file; staying at the reproduction gate", "error");
458
- refreshUi();
545
+ // The digest must be read before the active log is archived and truncated.
546
+ // Drop the poller first: on Windows a watched file can refuse CREATE_ALWAYS.
547
+ const hypotheses = describeHypotheses(summarizeHypotheses(closingRun ? readRunLines(closingRun) : []));
548
+ unwatchLogFile();
549
+ const created = createRun(state.debugDir, currentRound(state).index + 1, closingRun);
550
+ if ("error" in created) {
551
+ watchLogFile();
552
+ ctx.ui.notify(
553
+ `debug-mode: could not initialize the next run log file (${created.error}); staying on this round.${runLogHint(created.error)}`,
554
+ "error",
555
+ );
459
556
  return;
460
557
  }
461
- unwatchLogFile();
462
- refreshUi();
463
-
464
- const summary = userDetails
465
- ? `proceed with user details — analyzing run ${run} (${logCount} entries)`
466
- : `proceed — analyzing run ${run} (${logCount} entries)`;
467
- pi.sendMessage(
468
- {
469
- customType: "debug-mode-proceed",
470
- content: buildProceedMessage({
471
- run,
472
- logCount,
473
- userDetails,
474
- hypotheses,
475
- evidenceSummary: evidenceSummary(state, previousRound),
476
- }),
477
- display: true,
478
- details: { summary } satisfies DebugMessageDetails,
479
- },
480
- { triggerTurn: true },
481
- );
558
+ dispatch({ t: "proceed", runId: created.runId, logCount, hypotheses, details: userDetails, now: Date.now() }, ctx);
482
559
  }
483
560
 
484
- async function teardown(ctx: ExtensionContext, outcome: "finished" | "aborted"): Promise<void> {
485
- unwatchLogFile();
486
- lineCounter.clear();
487
- if (state.debugDir) {
488
- try {
489
- fs.rmSync(state.debugDir, { recursive: true, force: true });
490
- pruneDebugRoot(ctx.cwd);
491
- } catch (err) {
492
- pi.logger.warn("debug-mode: failed to remove debug dir", { err });
493
- }
494
- }
495
- const scan = await syncLedger(state);
496
- const probesLeft = [...scan.alive, ...scan.unknown];
497
- Object.assign(state, freshState());
498
- pi.appendEntry(DEBUG_ENTRY, { ...state });
499
- refreshUi();
500
- const resultLabel = outcome === "finished" ? "finished" : "aborted";
501
- if (probesLeft.length > 0) {
502
- ctx.ui.notify(
503
- `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.`,
504
- "warning",
505
- );
506
- } else {
507
- ctx.ui.notify(
508
- `Debug mode ${resultLabel}. Log files removed; applied fixes remain in the working diff for review.`,
509
- "info",
561
+ async function markDebugFixed(ctx: ExtensionCommandContext): Promise<void> {
562
+ if (!(await takeTurn(ctx, "/debug-done from"))) return;
563
+ const logCount = currentLogCount();
564
+ if (!state.active) return;
565
+ if (logCount === 0 && !hasNonProbeEvidence(currentRound(state)) && ctx.hasUI) {
566
+ const confirmed = await ctx.ui.confirm(
567
+ "/debug-done without runtime logs?",
568
+ "No runtime observations were captured for this round. Mark the problem as fixed anyway?",
510
569
  );
570
+ if (!confirmed) return;
511
571
  }
572
+ if (!state.active || state.stage !== "user_turn") return;
573
+ dispatch({ t: "mark_fixed" }, ctx);
512
574
  }
513
575
 
514
576
  async function abortDebug(ctx: ExtensionContext): Promise<void> {
515
- if (!state.active) {
516
- ctx.ui.notify("debug-mode: not active", "error");
517
- return;
518
- }
577
+ if (!activeSession(ctx)) return;
519
578
  if (ctx.hasUI) {
520
579
  const confirmed = await ctx.ui.confirm(
521
580
  "Abort debug mode?",
@@ -524,23 +583,38 @@ function enterGate(
524
583
  if (!confirmed) return;
525
584
  }
526
585
  if (!state.active) return;
527
- await teardown(ctx, "aborted");
586
+ dispatch({ t: "abort" }, ctx);
587
+ await setDebugToolsActive(false);
528
588
  }
529
589
 
530
590
  /**
531
- * Attach one user-provided evidence file to the current waiting round. The
532
- * file is referenced in place — never copied, moved or deleted — and the
533
- * action stays at the gate: no model-visible message, no agent turn.
591
+ * Attach one user-provided evidence file to the current round. The file is
592
+ * referenced in place — never copied, moved or deleted — and the action does
593
+ * not advance the workflow: no model-visible message, no agent turn.
594
+ *
595
+ * The argument is parsed after the turn is claimed, so the link selector is
596
+ * resolved against settled state rather than against whatever the session
597
+ * looked like while an agent turn was still running.
534
598
  */
535
- async function attachEvidence(ctx: ExtensionContext, rawPath?: string, requestId: string | null = null): Promise<boolean> {
536
- if (!ensureWaiting(ctx)) return false;
537
- let input = rawPath?.trim() ?? "";
599
+ async function attachEvidence(ctx: ExtensionCommandContext, args: string): Promise<boolean> {
600
+ // Attaching is not a workflow decision, so it claims the turn without the
601
+ // "continue anyway?" confirmation the advancing commands need.
602
+ const session = await ownTurn(ctx);
603
+ if (!session) return false;
604
+ const parsed = parseEvidenceArgument(args, session);
605
+ if (!parsed.ok) {
606
+ ctx.ui.notify(`debug-mode: ${parsed.error}`, "error");
607
+ return false;
608
+ }
609
+ const requestId = parsed.requestId;
610
+ let input = parsed.rawPath?.trim() ?? "";
538
611
  if (!input) {
539
612
  if (!ctx.hasUI) {
540
- ctx.ui.notify(`Usage: /${COMMAND_EVIDENCE} <path>`, "error");
613
+ ctx.ui.notify(`Usage: /${COMMAND_EVIDENCE} <request-id|${UNLINKED_SELECTOR}> <path>`, "error");
541
614
  return false;
542
615
  }
543
- input = (await ctx.ui.input("Path to debug evidence file", "absolute or cwd-relative path")) ?? "";
616
+ const target = requestId ? `evidence file for ${requestId}` : "unlinked evidence file";
617
+ input = (await ctx.ui.input(`Path to ${target}`, "absolute or cwd-relative path")) ?? "";
544
618
  if (!input.trim()) {
545
619
  ctx.ui.notify("debug-mode: no evidence file path provided", "error");
546
620
  return false;
@@ -564,20 +638,15 @@ function enterGate(
564
638
  );
565
639
  if (!confirmed) return false;
566
640
  }
567
- const result = addEvidenceArtifact(state, trimmed, ctx.cwd, requestId);
568
- if ("error" in result) {
569
- ctx.ui.notify(`debug-mode: evidence rejected — ${result.error}`, "error");
641
+ const validation = validateEvidenceArtifact(trimmed, ctx.cwd);
642
+ if (!validation.ok) {
643
+ ctx.ui.notify(`debug-mode: evidence rejected — ${validation.reason}`, "error");
570
644
  return false;
571
645
  }
572
- Object.assign(state, result.state);
573
- pi.appendEntry(DEBUG_ENTRY, { ...state });
574
- refreshUi();
575
- ctx.ui.notify(
576
- `debug-mode: attached ${result.artifact.id} → ${result.artifact.path} (${result.artifact.size} bytes)`,
577
- "info",
578
- );
646
+ if (!state.active) return false;
647
+ dispatch({ t: "attach_artifact", candidate: validation.artifact, requestId, now: Date.now() }, ctx);
579
648
  return true;
580
- }
649
+ }
581
650
 
582
651
  // ============================== commands ==============================
583
652
 
@@ -597,7 +666,7 @@ function enterGate(
597
666
  );
598
667
  return;
599
668
  }
600
- startDebug(ctx, problem);
669
+ await startDebug(ctx, problem);
601
670
  },
602
671
  });
603
672
 
@@ -618,36 +687,15 @@ function enterGate(
618
687
  },
619
688
  });
620
689
 
621
- /**
622
- * Parse `/debug-evidence [<request-id>] <path>`: when the first
623
- * whitespace-delimited token names a pending current-round user_artifact
624
- * request, link to it and treat the remainder (spaces intact) as the path;
625
- * otherwise the whole argument is an unlinked path.
626
- */
627
- function parseEvidenceArgument(args: string): { requestId: string | null; rawPath: string | undefined } {
628
- const trimmed = args.trim();
629
- if (!trimmed) return { requestId: null, rawPath: undefined };
630
- const firstToken = trimmed.split(/\s+/, 1)[0];
631
- const isPending = pendingEvidenceRequests(state, state.round).some(
632
- request => request.method === "user_artifact" && request.id === firstToken,
633
- );
634
- if (isPending) {
635
- const rest = trimmed.slice(firstToken.length).trim();
636
- return rest ? { requestId: firstToken, rawPath: rest } : { requestId: firstToken, rawPath: undefined };
637
- }
638
- return { requestId: null, rawPath: trimmed };
639
- }
640
-
641
690
  pi.registerCommand(COMMAND_EVIDENCE, {
642
- description: "Attach one user-provided evidence file: /debug-evidence [<request-id>] <path> (no argument opens a path prompt)",
691
+ description: `Attach one user-provided evidence file: /${COMMAND_EVIDENCE} <request-id|${UNLINKED_SELECTOR}> <path> (selector only opens a path prompt)`,
692
+ ...argumentCompletions(prefix => evidenceCompletions(prefix, state)),
643
693
  handler: async (args, ctx) => {
644
694
  uiCtx = ctx;
645
- const { requestId, rawPath } = parseEvidenceArgument(args);
646
- await attachEvidence(ctx, rawPath, requestId);
695
+ await attachEvidence(ctx, args);
647
696
  },
648
697
  });
649
698
 
650
-
651
699
  pi.registerCommand(COMMAND_ABORT, {
652
700
  description: "Abort debug mode: delete logs (fixes stay in the working diff)",
653
701
  handler: async (_args, ctx) => {
@@ -665,11 +713,15 @@ function enterGate(
665
713
  return;
666
714
  }
667
715
  refreshLogCounts();
668
- const scan = await syncLedger(state);
669
- const tallies = state.runId ? summarizeHypotheses(readRunLines(state.runId)) : [];
670
- const roundRequests = state.evidenceRequests.filter(request => request.round === state.round);
671
- const pending = pendingEvidenceRequests(state, state.round);
672
- const unavailable = state.evidenceArtifacts.filter(artifact => {
716
+ const scan = await syncLedger();
717
+ if (!state.active) return;
718
+ const session: DebugSession = state;
719
+ const round = currentRound(session);
720
+ const run = activeRunId(session);
721
+ const tallies = run ? summarizeHypotheses(readRunLines(run)) : [];
722
+ const requests = round.plan ?? [];
723
+ const pending = pendingRequests(session, round);
724
+ const unavailable = session.artifacts.filter(artifact => {
673
725
  try {
674
726
  const stats = fs.statSync(artifact.path);
675
727
  fs.accessSync(artifact.path, fs.constants.R_OK);
@@ -679,25 +731,22 @@ function enterGate(
679
731
  }
680
732
  });
681
733
  ctx.ui.notify(
682
- `debug-mode: phase=${state.phase} round=${state.round} run=${state.runId}\n` +
734
+ `debug-mode: stage=${session.stage} round=${round.index} run=${run ?? "(none)"}\n` +
735
+ (round.handoff ? `${describeHandoff(round.handoff, round.index, round.plan === null)}\n` : "") +
683
736
  `${describeLedger(scan)}\n` +
684
- `logs: ${state.runHistory.map(r => `${r}=${state.logCounts[r] ?? 0}`).join(", ") || "(none)"}\n` +
737
+ `logs: ${session.runHistory.map(r => `${r}=${session.logCounts[r] ?? 0}`).join(", ") || "(none)"}\n` +
685
738
  `this run by hypothesis: ${describeHypotheses(tallies)}\n` +
686
- `current log file: ${logFileFor(state) ?? "(not initialized)"}\n` +
687
- `evidence requests: ${roundRequests.map(r => `${r.id}[${r.method}] ${r.title}`).join("; ") || "(none)"}\n` +
739
+ `current log file: ${logFileFor(session) ?? "(not initialized)"}\n` +
740
+ `evidence requests: ${requests.map(r => `${r.id}[${r.method}] ${r.title}`).join("; ") || "(none)"}\n` +
688
741
  `pending: ${pending.map(r => r.id).join(", ") || "(none)"}\n` +
689
- `observations: ${state.evidenceObservations.map(o => o.id).join(", ") || "(none)"}\n` +
690
- `artifacts: ${state.evidenceArtifacts.map(a => a.id).join(", ") || "(none)"}` +
742
+ `observations: ${session.observations.map(o => o.id).join(", ") || "(none)"}\n` +
743
+ `artifacts: ${session.artifacts.map(a => a.id).join(", ") || "(none)"}` +
691
744
  (unavailable.length > 0 ? `\nunavailable artifacts: ${unavailable.map(a => `${a.id} ${a.path}`).join(", ")}` : ""),
692
745
  "info",
693
746
  );
694
747
  },
695
748
  });
696
749
 
697
- // ============================== tools ==============================
698
-
699
- registerDebugTools(pi, { state, refreshLogCounts, readRunLines });
700
-
701
750
  // ============================== lifecycle ==============================
702
751
 
703
752
  pi.on("session_start", async (_event, ctx) => {
@@ -705,21 +754,24 @@ function enterGate(
705
754
  const entries = ctx.sessionManager.getEntries();
706
755
  const last = entries
707
756
  .filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === DEBUG_ENTRY)
708
- .pop() as { data?: DebugState } | undefined;
709
- if (last?.data?.active) {
710
- Object.assign(state, last.data);
711
- if (!Array.isArray(state.reproductionSteps)) state.reproductionSteps = [];
712
- if (!Array.isArray(state.evidenceRequests)) state.evidenceRequests = [];
713
- if (!Array.isArray(state.evidenceArtifacts)) state.evidenceArtifacts = [];
714
- if (!Array.isArray(state.evidenceObservations)) state.evidenceObservations = [];
715
- if (!Array.isArray(state.runHistory)) state.runHistory = state.runId ? [state.runId] : [];
716
- if (typeof state.gateNudges !== "number") state.gateNudges = 0;
717
- if (!state.phase) state.phase = "waiting";
718
- if (!state.debugDir || !fs.existsSync(state.debugDir)) initializeLogDirectory(ctx);
757
+ .pop() as { data?: unknown } | undefined;
758
+ state = reviveState(last?.data);
759
+ if (state.active) {
760
+ if (!state.debugDir || !fs.existsSync(state.debugDir)) {
761
+ const debugDir = debugDirFor(ctx.cwd, ctx.sessionManager.getSessionId());
762
+ try {
763
+ fs.mkdirSync(debugDir, { recursive: true });
764
+ excludeDebugLogsFromGit(ctx.cwd);
765
+ state = { ...state, debugDir };
766
+ } catch (err) {
767
+ pi.logger.error("debug-mode: cannot restore log dir", { dir: debugDir, err });
768
+ }
769
+ }
719
770
  const currentFile = logFileFor(state);
720
771
  if (currentFile) {
721
772
  try {
722
- const legacyRunFile = state.debugDir && state.runId ? path.join(state.debugDir, `${state.runId}.jsonl`) : null;
773
+ const run = activeRunId(state);
774
+ const legacyRunFile = state.debugDir && run ? path.join(state.debugDir, `${run}.jsonl`) : null;
723
775
  if (!fs.existsSync(currentFile) && legacyRunFile && fs.existsSync(legacyRunFile)) {
724
776
  fs.renameSync(legacyRunFile, currentFile);
725
777
  }
@@ -729,19 +781,21 @@ function enterGate(
729
781
  }
730
782
  }
731
783
  refreshLogCounts();
732
- ctx.ui.notify(
733
- `debug-mode resumed: phase=${state.phase} round=${state.round}, log=${currentFile ?? "unavailable"}; evidence: ${evidenceSummary(state, state.round)}. Use /${COMMAND_STATUS}, /${COMMAND_EVIDENCE}, /${COMMAND_DONE}, /${COMMAND_PROCEED}, or /${COMMAND_ABORT}.`,
734
- "info",
735
- );
784
+ if (state.active) {
785
+ const round = currentRound(state);
786
+ ctx.ui.notify(
787
+ `debug-mode resumed: round ${round.index}, log=${currentFile ?? "unavailable"}; evidence: ${evidenceSummary(state)}.\n` +
788
+ `${describeHandoff(round.handoff ?? "incomplete", round.index, round.plan === null)}\n` +
789
+ `Use /${COMMAND_STATUS}, /${COMMAND_EVIDENCE}, /${COMMAND_DONE}, /${COMMAND_PROCEED}, or /${COMMAND_ABORT}.`,
790
+ "info",
791
+ );
792
+ }
736
793
  }
794
+ await setDebugToolsActive(state.active);
737
795
  refreshUi();
738
796
  watchLogFile();
739
797
  });
740
798
 
741
- pi.on("turn_start", async () => {
742
- if (state.active) pi.appendEntry(DEBUG_ENTRY, { ...state });
743
- });
744
-
745
799
  pi.on("session_shutdown", async () => {
746
800
  unwatchLogFile();
747
801
  });