@siuver/omp-debug-mode 0.1.5 → 0.1.7

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,14 +1,27 @@
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 { describeEvidence, validateEvidenceArtifact } from "./evidence";
6
- import { describeOpenReason } 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,
10
22
  describeHypotheses,
11
23
  prepareRunLog,
24
+ clearActiveLog,
12
25
  readJsonlLines,
13
26
  summarizeHypotheses,
14
27
  } from "./log-files";
@@ -28,12 +41,12 @@ import {
28
41
  evidenceSummary,
29
42
  evidenceView,
30
43
  hasNonProbeEvidence,
31
- keepLatestCustomType,
32
44
  logFileFor,
33
45
  pendingRequests,
34
46
  reviveState,
47
+ syncCustomType,
35
48
  } from "./state";
36
- import { registerDebugTools } from "./tools";
49
+ import { type HandoffOutcome, type HandoffRequest, nextActiveTools, registerDebugTools } from "./tools";
37
50
  import { applyUi } from "./ui";
38
51
  import { debugDirFor, excludeDebugLogsFromGit, pruneDebugRoot } from "./workspace";
39
52
 
@@ -43,6 +56,7 @@ const COMMAND_PROCEED = "debug-proceed";
43
56
  const COMMAND_EVIDENCE = "debug-evidence";
44
57
  const COMMAND_ABORT = "debug-abort";
45
58
  const COMMAND_STATUS = "debug-status";
59
+ const COMMAND_CLEAR = "debug-clear";
46
60
 
47
61
  /** Compact transcript lines for the prompts this extension injects. */
48
62
  const MESSAGE_SUMMARIES: Record<string, string> = {
@@ -55,6 +69,16 @@ interface DebugMessageDetails {
55
69
  summary?: string;
56
70
  }
57
71
 
72
+ /**
73
+ * Argument completion reached the host's command options after the version this
74
+ * package type-checks against, hence the spread instead of an inline key.
75
+ */
76
+ function argumentCompletions(provide: (argumentPrefix: string) => EvidenceCompletion[] | null): {
77
+ getArgumentCompletions?: (argumentPrefix: string) => EvidenceCompletion[] | null;
78
+ } {
79
+ return { getArgumentCompletions: provide };
80
+ }
81
+
58
82
  export function registerDebugMode(pi: ExtensionAPI): void {
59
83
  let state: DebugState = INACTIVE;
60
84
  let uiCtx: ExtensionContext | null = null;
@@ -72,10 +96,12 @@ export function registerDebugMode(pi: ExtensionAPI): void {
72
96
  // ============================== state plumbing ==============================
73
97
 
74
98
  /**
75
- * Cache-only events carry no effects, so they may run inside a render pass
76
- * without re-entering the UI refresh that asked for them.
99
+ * Effect-free bookkeeping: these events carry no effects, so they may run
100
+ * inside a render pass without re-entering the UI refresh that asked for
101
+ * them, and they are deliberately not persisted — every one of them is
102
+ * either a disk observation or live-turn state that a restore rebuilds.
77
103
  */
78
- function absorbCache(event: Extract<DebugEvent, { t: "runs_observed" | "ledger_synced" }>): void {
104
+ function absorbCache(event: Extract<DebugEvent, { t: "runs_observed" | "ledger_synced" | "tool_used" }>): void {
79
105
  state = reduce(state, event).state;
80
106
  }
81
107
 
@@ -138,6 +164,7 @@ export function registerDebugMode(pi: ExtensionAPI): void {
138
164
  "info",
139
165
  );
140
166
  }
167
+ void setDebugToolsActive(false);
141
168
  return null;
142
169
  }
143
170
  }
@@ -193,8 +220,27 @@ export function registerDebugMode(pi: ExtensionAPI): void {
193
220
  return run ? (state.logCounts[run] ?? 0) : 0;
194
221
  }
195
222
 
223
+ function fsErrorMessage(error: unknown): string {
224
+ if (error instanceof Error) {
225
+ const code = "code" in error && typeof error.code === "string" ? error.code : "";
226
+ return code ? `${code}: ${error.message}` : error.message;
227
+ }
228
+ return String(error);
229
+ }
230
+
231
+ function runLogHint(error: string): string {
232
+ if (/\b(EPERM|EBUSY|EACCES|EAGAIN)\b/.test(error)) {
233
+ return " Close the instrumented app if it still has the log file open, then retry.";
234
+ }
235
+ return "";
236
+ }
237
+
196
238
  /** Archive the active log, truncate it for the next reproduction, and name the run. */
197
- function createRun(debugDir: string, round: number, previousRun: string | null): string | null {
239
+ function createRun(
240
+ debugDir: string,
241
+ round: number,
242
+ previousRun: string | null,
243
+ ): { runId: string } | { error: string } {
198
244
  try {
199
245
  prepareRunLog(debugDir, previousRun);
200
246
  } catch (err) {
@@ -202,9 +248,9 @@ export function registerDebugMode(pi: ExtensionAPI): void {
202
248
  file: path.join(debugDir, ACTIVE_LOG_FILE),
203
249
  err,
204
250
  });
205
- return null;
251
+ return { error: fsErrorMessage(err) };
206
252
  }
207
- return `run${round}-${Date.now().toString(36)}`;
253
+ return { runId: `run${round}-${Date.now().toString(36)}` };
208
254
  }
209
255
 
210
256
  /**
@@ -213,13 +259,13 @@ export function registerDebugMode(pi: ExtensionAPI): void {
213
259
  * refresh the widget as observations land.
214
260
  */
215
261
  function watchLogFile(): void {
216
- const file = state.active && state.stage === "awaiting_evidence" ? logFileFor(state) : null;
262
+ const file = state.active && state.stage === "user_turn" ? logFileFor(state) : null;
217
263
  if (file === watchedLogFile) return;
218
264
  unwatchLogFile();
219
265
  if (!file || !uiCtx?.hasUI) return;
220
266
  try {
221
267
  fs.watchFile(file, { interval: 1000 }, (curr, prev) => {
222
- if (!state.active || state.stage !== "awaiting_evidence") return;
268
+ if (!state.active || state.stage !== "user_turn") return;
223
269
  if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return;
224
270
  try {
225
271
  refreshUi();
@@ -254,6 +300,9 @@ export function registerDebugMode(pi: ExtensionAPI): void {
254
300
 
255
301
  pi.on("tool_call", async (event, ctx) => {
256
302
  if (!state.active) return;
303
+ // Any tool call clears the reminder guard: the agent acted on the last
304
+ // reminder, so a further one is worth spending.
305
+ absorbCache({ t: "tool_used" });
257
306
  if (event.toolName !== "edit" && event.toolName !== "write") return;
258
307
  const probes = probesInInput(event.input as Record<string, unknown>, ctx.cwd, currentRound(state).index);
259
308
  if (probes.length > 0) dispatch({ t: "probes_found", probes }, ctx);
@@ -261,29 +310,49 @@ export function registerDebugMode(pi: ExtensionAPI): void {
261
310
 
262
311
  // ============================== prompt injection ==============================
263
312
 
313
+ /** The blackboard plus the contract for the stage the session is in now. */
314
+ function injectedContext(session: DebugSession): string {
315
+ // Cleanup has no hypotheses left to form; the full methodology would only
316
+ // invite another round.
317
+ const contract = session.stage === "cleaning_up" ? CLEANUP_CONTRACT : METHODOLOGY;
318
+ return `${blackboard(session, describeEvidence(evidenceView(session)))}\n\n${contract}`;
319
+ }
320
+
321
+ // Injected on every prompt, including a reply on the user's turn. Skipping the
322
+ // injection there is what left the model with no idea which stage it was in,
323
+ // and a model that cannot see the state cannot be blamed for misjudging it.
324
+ //
325
+ // The attribution is explicit because the host otherwise inherits it from the
326
+ // prompt being answered, which stamps this extension's own blackboard as
327
+ // user-authored. Models read that literally and report the stage back as
328
+ // something the user said.
264
329
  pi.on("before_agent_start", async () => {
265
- if (!state.active || state.stage === "awaiting_evidence") return;
330
+ if (!state.active) return;
266
331
  // The blackboard claims to be ground truth, so reconcile it with disk first.
267
332
  await syncLedger();
268
333
  if (!state.active) return;
269
- // Cleanup has no hypotheses left to form; the full methodology would only
270
- // invite another round.
271
- const contract = state.stage === "cleaning_up" ? CLEANUP_CONTRACT : METHODOLOGY;
272
334
  return {
273
335
  message: {
274
336
  customType: DEBUG_CONTEXT_TYPE,
275
- content: `${blackboard(state, describeEvidence(evidenceView(state)))}\n\n${contract}`,
337
+ content: injectedContext(state),
276
338
  display: false,
339
+ attribution: "agent",
277
340
  },
278
341
  };
279
342
  });
280
343
 
281
- // Drop stale blackboard copies. Keep the newest `context` runs after
282
- // `before_agent_start`, so deleting every match would hide the injection
283
- // from the model.
344
+ // Keep exactly one, current blackboard in the request. `before_agent_start`
345
+ // only fires for a submitted prompt, so a turn the host resumed by itself —
346
+ // a todo reminder, a plan nudge, a queued-message drain — would otherwise
347
+ // hand the model the previous turn's stage. This hook runs on every provider
348
+ // request, so re-rendering here is what makes the stage unskippable.
284
349
  pi.on("context", async (event) => {
285
- const filtered = keepLatestCustomType(event.messages, DEBUG_CONTEXT_TYPE);
286
- if (filtered.length !== event.messages.length) return { messages: filtered };
350
+ const messages = syncCustomType(
351
+ event.messages,
352
+ DEBUG_CONTEXT_TYPE,
353
+ state.active ? injectedContext(state) : null,
354
+ );
355
+ if (messages) return { messages };
287
356
  });
288
357
 
289
358
  // ============================== turn lifecycle ==============================
@@ -295,6 +364,13 @@ export function registerDebugMode(pi: ExtensionAPI): void {
295
364
  pi.on("message_end", async (event, ctx) => {
296
365
  if (!state.active) return;
297
366
  const msg = event.message as { role?: string; content?: unknown };
367
+ // A user message is the one thing a host continuation cannot fake: its
368
+ // reminders are `developer` messages. So this, not the start of a turn,
369
+ // is what hands a round back to the agent.
370
+ if (msg?.role === "user") {
371
+ dispatch({ t: "user_replied" }, ctx);
372
+ return;
373
+ }
298
374
  if (msg?.role !== "assistant") return;
299
375
  dispatch({ t: "assistant_message", text: extractAssistantText(msg.content) }, ctx);
300
376
  });
@@ -306,10 +382,63 @@ export function registerDebugMode(pi: ExtensionAPI): void {
306
382
  // disk, so the ledger is reconciled before the round may close.
307
383
  await syncLedger();
308
384
  const context = dispatch({ t: "turn_settled" }, ctx);
385
+ await setDebugToolsActive(state.active);
309
386
  if (context) return { continue: true, additionalContext: context };
310
387
  });
311
388
 
312
- registerDebugTools(pi, { getState: () => state, refreshLogCounts, readRunLines, syncLedger });
389
+ /**
390
+ * Apply an explicit handoff from the Agent tool. The machine only accepts one
391
+ * while the agent actually holds the round, so a duplicated or late call is
392
+ * reported back to the model instead of silently rewriting the user's turn.
393
+ */
394
+ function applyHandoffRequest(request: HandoffRequest, ctx: ExtensionContext): HandoffOutcome {
395
+ if (!state.active) return { ok: false, error: "debug mode is not active." };
396
+ if (state.stage !== "investigating") {
397
+ return {
398
+ ok: false,
399
+ error: `round ${currentRound(state).index} is already with the user (stage: ${state.stage}); do not hand off twice.`,
400
+ };
401
+ }
402
+ const round = currentRound(state);
403
+ const requests = (request.plan ?? round.plan ?? []).length;
404
+ dispatch({ t: "handoff", mode: request.mode, steps: request.steps, plan: request.plan }, ctx);
405
+ if (!state.active) return { ok: false, error: "debug mode ended while handing off." };
406
+ return {
407
+ ok: true,
408
+ summary:
409
+ `Round ${round.index} is now with the user as "${request.mode}": ${request.steps.length} step(s) and ` +
410
+ `${requests} evidence request(s) are showing in their widget, and /debug-proceed is available. ` +
411
+ "Stop here — the user reproduces or replies out-of-band.",
412
+ };
413
+ }
414
+
415
+ registerDebugTools(pi, {
416
+ getState: () => state,
417
+ refreshLogCounts,
418
+ readRunLines,
419
+ syncLedger,
420
+ handOff: applyHandoffRequest,
421
+ });
422
+
423
+ /**
424
+ * The four debug tools are registered at plugin load as `defaultInactive`, so
425
+ * they are not in the model's schema on an ordinary session. They join the
426
+ * active set when `/debug-mode` starts (or a resumed session is already in
427
+ * debug mode) and leave it on teardown. `setActiveTools` replaces the whole
428
+ * enabled list, so this only adds or removes our names.
429
+ */
430
+ async function setDebugToolsActive(wanted: boolean): Promise<void> {
431
+ const getActive = pi.getActiveTools?.bind(pi);
432
+ const setActive = pi.setActiveTools?.bind(pi);
433
+ if (!getActive || !setActive) return;
434
+ const next = nextActiveTools(getActive(), wanted);
435
+ if (!next) return;
436
+ try {
437
+ await setActive(next);
438
+ } catch (err) {
439
+ pi.logger.warn("debug-mode: cannot update the active tool set", { err, wanted });
440
+ }
441
+ }
313
442
 
314
443
  // ============================== command guards ==============================
315
444
 
@@ -321,38 +450,52 @@ export function registerDebugMode(pi: ExtensionAPI): void {
321
450
  return state;
322
451
  }
323
452
 
324
- function ownsTurn(session: DebugSession): boolean {
325
- return session.stage === "awaiting_evidence" || session.stage === "open";
326
- }
327
-
328
453
  /**
329
- * The debug commands belong to the user's turn. An unclosed round is still
330
- * the user's move, so it gets a confirmation rather than the flat "not
331
- * waiting for reproduction" refusal that made an open round look stuck.
454
+ * Wait out any agent turn, then make sure the round really is with the user.
455
+ *
456
+ * The turn is claimed rather than inspected. An aborted turn never reaches
457
+ * `session_stop`, so a round could sit in `investigating` for the rest of the
458
+ * session with every debug command refused; waiting for idle turns "the agent
459
+ * is working" into a fact instead of a guess, and anything still unsettled
460
+ * afterwards is reclassified on the spot.
332
461
  */
333
- async function claimTurn(ctx: ExtensionContext, action: string): Promise<boolean> {
334
- const session = activeSession(ctx);
335
- if (!session) return false;
336
- if (!ownsTurn(session)) {
462
+ async function ownTurn(ctx: ExtensionCommandContext): Promise<DebugSession | null> {
463
+ if (!activeSession(ctx)) return null;
464
+ await ctx.waitForIdle();
465
+ if (!state.active) return null;
466
+ if (state.stage === "cleaning_up") {
337
467
  ctx.ui.notify(
338
- `debug-mode: the agent still has round ${currentRound(session).index} (stage: ${session.stage}) wait for it to stop.`,
468
+ `debug-mode: cleanup is still finishing reply to the agent, or use /${COMMAND_ABORT}.`,
339
469
  "error",
340
470
  );
341
- return false;
471
+ return null;
342
472
  }
343
- if (session.stage === "open" && ctx.hasUI) {
473
+ if (state.stage === "investigating") dispatch({ t: "reclaim" }, ctx);
474
+ return state.active && state.stage === "user_turn" ? state : null;
475
+ }
476
+
477
+ /**
478
+ * `ownTurn` plus a confirmation for the rounds where continuing is a judgment
479
+ * call: the agent either asked a question or never said what to capture.
480
+ */
481
+ async function takeTurn(ctx: ExtensionCommandContext, action: string): Promise<DebugSession | null> {
482
+ const session = await ownTurn(ctx);
483
+ if (!session) return null;
484
+ const round = currentRound(session);
485
+ const mode = round.handoff ?? "incomplete";
486
+ if ((mode === "incomplete" || mode === "question") && ctx.hasUI) {
344
487
  const confirmed = await ctx.ui.confirm(
345
- `${action} an unclosed round?`,
346
- `${describeOpenReason(currentRound(session).openReason ?? "awaiting_reply", currentRound(session).index)} Continue with the evidence that exists?`,
488
+ `${action} round ${round.index}?`,
489
+ `${describeHandoff(mode, round.index)} Continue with the evidence that already exists?`,
347
490
  );
348
- if (!confirmed) return false;
491
+ if (!confirmed) return null;
349
492
  }
350
- return true;
493
+ return state.active ? state : null;
351
494
  }
352
495
 
353
496
  // ============================== round transitions ==============================
354
497
 
355
- function startDebug(ctx: ExtensionContext, problem: string): void {
498
+ async function startDebug(ctx: ExtensionContext, problem: string): Promise<void> {
356
499
  const debugDir = debugDirFor(ctx.cwd, ctx.sessionManager.getSessionId());
357
500
  try {
358
501
  fs.mkdirSync(debugDir, { recursive: true });
@@ -362,23 +505,30 @@ export function registerDebugMode(pi: ExtensionAPI): void {
362
505
  ctx.ui.notify("debug-mode: could not create the debug log directory; debug mode was not started", "error");
363
506
  return;
364
507
  }
365
- const runId = createRun(debugDir, 1, null);
366
- if (!runId) {
367
- ctx.ui.notify("debug-mode: could not initialize the run log file; debug mode was not started", "error");
508
+ const created = createRun(debugDir, 1, null);
509
+ if ("error" in created) {
510
+ ctx.ui.notify(
511
+ `debug-mode: could not initialize the run log file (${created.error}); debug mode was not started.${runLogHint(created.error)}`,
512
+ "error",
513
+ );
368
514
  return;
369
515
  }
516
+ const runId = created.runId;
517
+ // The start prompt fires a turn immediately, so the four tools must already
518
+ // be in the schema or round 1 cannot close with hand_off_to_user.
519
+ await setDebugToolsActive(true);
370
520
  dispatch({ t: "start", problem, debugDir, runId, logFile: path.join(debugDir, ACTIVE_LOG_FILE) }, ctx);
371
521
  }
372
522
 
373
- async function advanceDebug(ctx: ExtensionContext, userDetails?: string): Promise<void> {
374
- if (!(await claimTurn(ctx, "Proceed from"))) return;
523
+ async function advanceDebug(ctx: ExtensionCommandContext, userDetails?: string): Promise<void> {
524
+ if (!(await takeTurn(ctx, "/debug-proceed from"))) return;
375
525
  refreshLogCounts();
376
526
  if (!state.active) return;
377
527
  const closingRun = activeRunId(state);
378
528
  const logCount = closingRun ? (state.logCounts[closingRun] ?? 0) : 0;
379
529
  if (logCount === 0 && !userDetails && !hasNonProbeEvidence(currentRound(state)) && ctx.hasUI) {
380
530
  const confirmed = await ctx.ui.confirm(
381
- "Proceed without runtime logs?",
531
+ "/debug-proceed without runtime logs?",
382
532
  "No runtime observations were captured for this round. Continue to log analysis anyway?",
383
533
  );
384
534
  if (!confirmed) return;
@@ -387,35 +537,41 @@ export function registerDebugMode(pi: ExtensionAPI): void {
387
537
  const stillPending = pendingRequests(state);
388
538
  if (stillPending.length > 0 && ctx.hasUI) {
389
539
  const confirmed = await ctx.ui.confirm(
390
- "Proceed without all requested evidence?",
540
+ "/debug-proceed without all requested evidence?",
391
541
  `${stillPending.length} evidence request(s) still pending (${stillPending.map(r => r.id).join(", ")}). Continue anyway?`,
392
542
  );
393
543
  if (!confirmed) return;
394
544
  }
395
- if (!state.active || !ownsTurn(state) || !state.debugDir) return;
545
+ if (!state.active || state.stage !== "user_turn" || !state.debugDir) return;
396
546
 
397
547
  // The digest must be read before the active log is archived and truncated.
548
+ // Drop the poller first: on Windows a watched file can refuse CREATE_ALWAYS.
398
549
  const hypotheses = describeHypotheses(summarizeHypotheses(closingRun ? readRunLines(closingRun) : []));
399
- const runId = createRun(state.debugDir, currentRound(state).index + 1, closingRun);
400
- if (!runId) {
401
- ctx.ui.notify("debug-mode: could not initialize the next run log file; staying on this round", "error");
550
+ unwatchLogFile();
551
+ const created = createRun(state.debugDir, currentRound(state).index + 1, closingRun);
552
+ if ("error" in created) {
553
+ watchLogFile();
554
+ ctx.ui.notify(
555
+ `debug-mode: could not initialize the next run log file (${created.error}); staying on this round.${runLogHint(created.error)}`,
556
+ "error",
557
+ );
402
558
  return;
403
559
  }
404
- dispatch({ t: "proceed", runId, logCount, hypotheses, details: userDetails, now: Date.now() }, ctx);
560
+ dispatch({ t: "proceed", runId: created.runId, logCount, hypotheses, details: userDetails, now: Date.now() }, ctx);
405
561
  }
406
562
 
407
- async function markDebugFixed(ctx: ExtensionContext): Promise<void> {
408
- if (!(await claimTurn(ctx, "Mark as fixed from"))) return;
563
+ async function markDebugFixed(ctx: ExtensionCommandContext): Promise<void> {
564
+ if (!(await takeTurn(ctx, "/debug-done from"))) return;
409
565
  const logCount = currentLogCount();
410
566
  if (!state.active) return;
411
567
  if (logCount === 0 && !hasNonProbeEvidence(currentRound(state)) && ctx.hasUI) {
412
568
  const confirmed = await ctx.ui.confirm(
413
- "Mark as fixed without runtime logs?",
569
+ "/debug-done without runtime logs?",
414
570
  "No runtime observations were captured for this round. Mark the problem as fixed anyway?",
415
571
  );
416
572
  if (!confirmed) return;
417
573
  }
418
- if (!state.active || !ownsTurn(state)) return;
574
+ if (!state.active || state.stage !== "user_turn") return;
419
575
  dispatch({ t: "mark_fixed" }, ctx);
420
576
  }
421
577
 
@@ -430,27 +586,72 @@ export function registerDebugMode(pi: ExtensionAPI): void {
430
586
  }
431
587
  if (!state.active) return;
432
588
  dispatch({ t: "abort" }, ctx);
589
+ await setDebugToolsActive(false);
590
+ }
591
+
592
+ /** Empty this round's live log without advancing. Archived runs stay. */
593
+ async function clearDebugLog(ctx: ExtensionCommandContext): Promise<void> {
594
+ if (!activeSession(ctx)) return;
595
+ await ctx.waitForIdle();
596
+ if (!state.active || !state.debugDir) return;
597
+ refreshLogCounts();
598
+ if (!state.active) return;
599
+ const discarded = currentLogCount();
600
+ unwatchLogFile();
601
+ try {
602
+ clearActiveLog(state.debugDir);
603
+ } catch (err) {
604
+ watchLogFile();
605
+ const error = fsErrorMessage(err);
606
+ pi.logger.error("debug-mode: cannot clear run log", {
607
+ file: path.join(state.debugDir, ACTIVE_LOG_FILE),
608
+ err,
609
+ });
610
+ ctx.ui.notify(
611
+ `debug-mode: could not clear the run log file (${error}).${runLogHint(error)}`,
612
+ "error",
613
+ );
614
+ return;
615
+ }
616
+ refreshLogCounts();
617
+ watchLogFile();
618
+ refreshUi();
619
+ ctx.ui.notify(
620
+ discarded === 0
621
+ ? "debug-mode: current run log is already empty"
622
+ : `debug-mode: cleared ${discarded === 1 ? "1 log entry" : `${discarded} log entries`} from this round`,
623
+ "info",
624
+ );
433
625
  }
434
626
 
435
627
  /**
436
628
  * Attach one user-provided evidence file to the current round. The file is
437
629
  * referenced in place — never copied, moved or deleted — and the action does
438
630
  * not advance the workflow: no model-visible message, no agent turn.
631
+ *
632
+ * The argument is parsed after the turn is claimed, so the link selector is
633
+ * resolved against settled state rather than against whatever the session
634
+ * looked like while an agent turn was still running.
439
635
  */
440
- async function attachEvidence(ctx: ExtensionContext, rawPath?: string, requestId: string | null = null): Promise<boolean> {
441
- const session = activeSession(ctx);
636
+ async function attachEvidence(ctx: ExtensionCommandContext, args: string): Promise<boolean> {
637
+ // Attaching is not a workflow decision, so it claims the turn without the
638
+ // "continue anyway?" confirmation the advancing commands need.
639
+ const session = await ownTurn(ctx);
442
640
  if (!session) return false;
443
- if (!ownsTurn(session)) {
444
- ctx.ui.notify(`debug-mode: cannot attach evidence while the agent is working (stage: ${session.stage})`, "error");
641
+ const parsed = parseEvidenceArgument(args, session);
642
+ if (!parsed.ok) {
643
+ ctx.ui.notify(`debug-mode: ${parsed.error}`, "error");
445
644
  return false;
446
645
  }
447
- let input = rawPath?.trim() ?? "";
646
+ const requestId = parsed.requestId;
647
+ let input = parsed.rawPath?.trim() ?? "";
448
648
  if (!input) {
449
649
  if (!ctx.hasUI) {
450
- ctx.ui.notify(`Usage: /${COMMAND_EVIDENCE} <path>`, "error");
650
+ ctx.ui.notify(`Usage: /${COMMAND_EVIDENCE} <request-id|${UNLINKED_SELECTOR}> <path>`, "error");
451
651
  return false;
452
652
  }
453
- input = (await ctx.ui.input("Path to debug evidence file", "absolute or cwd-relative path")) ?? "";
653
+ const target = requestId ? `evidence file for ${requestId}` : "unlinked evidence file";
654
+ input = (await ctx.ui.input(`Path to ${target}`, "absolute or cwd-relative path")) ?? "";
454
655
  if (!input.trim()) {
455
656
  ctx.ui.notify("debug-mode: no evidence file path provided", "error");
456
657
  return false;
@@ -502,7 +703,7 @@ export function registerDebugMode(pi: ExtensionAPI): void {
502
703
  );
503
704
  return;
504
705
  }
505
- startDebug(ctx, problem);
706
+ await startDebug(ctx, problem);
506
707
  },
507
708
  });
508
709
 
@@ -523,32 +724,12 @@ export function registerDebugMode(pi: ExtensionAPI): void {
523
724
  },
524
725
  });
525
726
 
526
- /**
527
- * Parse `/debug-evidence [<request-id>] <path>`: when the first
528
- * whitespace-delimited token names a pending current-round user_artifact
529
- * request, link to it and treat the remainder (spaces intact) as the path;
530
- * otherwise the whole argument is an unlinked path.
531
- */
532
- function parseEvidenceArgument(args: string): { requestId: string | null; rawPath: string | undefined } {
533
- const trimmed = args.trim();
534
- if (!trimmed) return { requestId: null, rawPath: undefined };
535
- const firstToken = trimmed.split(/\s+/, 1)[0];
536
- const isPending =
537
- state.active &&
538
- pendingRequests(state).some(request => request.method === "user_artifact" && request.id === firstToken);
539
- if (isPending) {
540
- const rest = trimmed.slice(firstToken.length).trim();
541
- return rest ? { requestId: firstToken, rawPath: rest } : { requestId: firstToken, rawPath: undefined };
542
- }
543
- return { requestId: null, rawPath: trimmed };
544
- }
545
-
546
727
  pi.registerCommand(COMMAND_EVIDENCE, {
547
- description: "Attach one user-provided evidence file: /debug-evidence [<request-id>] <path> (no argument opens a path prompt)",
728
+ description: `Attach one user-provided evidence file: /${COMMAND_EVIDENCE} <request-id|${UNLINKED_SELECTOR}> <path> (selector only opens a path prompt)`,
729
+ ...argumentCompletions(prefix => evidenceCompletions(prefix, state)),
548
730
  handler: async (args, ctx) => {
549
731
  uiCtx = ctx;
550
- const { requestId, rawPath } = parseEvidenceArgument(args);
551
- await attachEvidence(ctx, rawPath, requestId);
732
+ await attachEvidence(ctx, args);
552
733
  },
553
734
  });
554
735
 
@@ -560,6 +741,14 @@ export function registerDebugMode(pi: ExtensionAPI): void {
560
741
  },
561
742
  });
562
743
 
744
+ pi.registerCommand(COMMAND_CLEAR, {
745
+ description: "Empty the current run log so this round can record a fresh reproduction",
746
+ handler: async (_args, ctx) => {
747
+ uiCtx = ctx;
748
+ await clearDebugLog(ctx);
749
+ },
750
+ });
751
+
563
752
  pi.registerCommand(COMMAND_STATUS, {
564
753
  description: "Show debug mode state",
565
754
  handler: async (_args, ctx) => {
@@ -588,7 +777,7 @@ export function registerDebugMode(pi: ExtensionAPI): void {
588
777
  });
589
778
  ctx.ui.notify(
590
779
  `debug-mode: stage=${session.stage} round=${round.index} run=${run ?? "(none)"}\n` +
591
- (round.openReason ? `${describeOpenReason(round.openReason, round.index)}\n` : "") +
780
+ (round.handoff ? `${describeHandoff(round.handoff, round.index, round.plan === null)}\n` : "") +
592
781
  `${describeLedger(scan)}\n` +
593
782
  `logs: ${session.runHistory.map(r => `${r}=${session.logCounts[r] ?? 0}`).join(", ") || "(none)"}\n` +
594
783
  `this run by hypothesis: ${describeHypotheses(tallies)}\n` +
@@ -638,12 +827,16 @@ export function registerDebugMode(pi: ExtensionAPI): void {
638
827
  }
639
828
  refreshLogCounts();
640
829
  if (state.active) {
830
+ const round = currentRound(state);
641
831
  ctx.ui.notify(
642
- `debug-mode resumed: stage=${state.stage} round=${currentRound(state).index}, log=${currentFile ?? "unavailable"}; evidence: ${evidenceSummary(state)}. Use /${COMMAND_STATUS}, /${COMMAND_EVIDENCE}, /${COMMAND_DONE}, /${COMMAND_PROCEED}, or /${COMMAND_ABORT}.`,
832
+ `debug-mode resumed: round ${round.index}, log=${currentFile ?? "unavailable"}; evidence: ${evidenceSummary(state)}.\n` +
833
+ `${describeHandoff(round.handoff ?? "incomplete", round.index, round.plan === null)}\n` +
834
+ `Use /${COMMAND_STATUS}, /${COMMAND_EVIDENCE}, /${COMMAND_DONE}, /${COMMAND_PROCEED}, or /${COMMAND_ABORT}.`,
643
835
  "info",
644
836
  );
645
837
  }
646
838
  }
839
+ await setDebugToolsActive(state.active);
647
840
  refreshUi();
648
841
  watchLogFile();
649
842
  });