@siuver/omp-debug-mode 0.1.5 → 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 { 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,
@@ -28,12 +40,12 @@ import {
28
40
  evidenceSummary,
29
41
  evidenceView,
30
42
  hasNonProbeEvidence,
31
- keepLatestCustomType,
32
43
  logFileFor,
33
44
  pendingRequests,
34
45
  reviveState,
46
+ syncCustomType,
35
47
  } from "./state";
36
- import { registerDebugTools } from "./tools";
48
+ import { type HandoffOutcome, type HandoffRequest, nextActiveTools, registerDebugTools } from "./tools";
37
49
  import { applyUi } from "./ui";
38
50
  import { debugDirFor, excludeDebugLogsFromGit, pruneDebugRoot } from "./workspace";
39
51
 
@@ -55,6 +67,16 @@ interface DebugMessageDetails {
55
67
  summary?: string;
56
68
  }
57
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
+
58
80
  export function registerDebugMode(pi: ExtensionAPI): void {
59
81
  let state: DebugState = INACTIVE;
60
82
  let uiCtx: ExtensionContext | null = null;
@@ -72,10 +94,12 @@ export function registerDebugMode(pi: ExtensionAPI): void {
72
94
  // ============================== state plumbing ==============================
73
95
 
74
96
  /**
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.
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.
77
101
  */
78
- function absorbCache(event: Extract<DebugEvent, { t: "runs_observed" | "ledger_synced" }>): void {
102
+ function absorbCache(event: Extract<DebugEvent, { t: "runs_observed" | "ledger_synced" | "tool_used" }>): void {
79
103
  state = reduce(state, event).state;
80
104
  }
81
105
 
@@ -138,6 +162,7 @@ export function registerDebugMode(pi: ExtensionAPI): void {
138
162
  "info",
139
163
  );
140
164
  }
165
+ void setDebugToolsActive(false);
141
166
  return null;
142
167
  }
143
168
  }
@@ -193,8 +218,27 @@ export function registerDebugMode(pi: ExtensionAPI): void {
193
218
  return run ? (state.logCounts[run] ?? 0) : 0;
194
219
  }
195
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);
227
+ }
228
+
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
+
196
236
  /** 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 {
237
+ function createRun(
238
+ debugDir: string,
239
+ round: number,
240
+ previousRun: string | null,
241
+ ): { runId: string } | { error: string } {
198
242
  try {
199
243
  prepareRunLog(debugDir, previousRun);
200
244
  } catch (err) {
@@ -202,9 +246,9 @@ export function registerDebugMode(pi: ExtensionAPI): void {
202
246
  file: path.join(debugDir, ACTIVE_LOG_FILE),
203
247
  err,
204
248
  });
205
- return null;
249
+ return { error: fsErrorMessage(err) };
206
250
  }
207
- return `run${round}-${Date.now().toString(36)}`;
251
+ return { runId: `run${round}-${Date.now().toString(36)}` };
208
252
  }
209
253
 
210
254
  /**
@@ -213,13 +257,13 @@ export function registerDebugMode(pi: ExtensionAPI): void {
213
257
  * refresh the widget as observations land.
214
258
  */
215
259
  function watchLogFile(): void {
216
- const file = state.active && state.stage === "awaiting_evidence" ? logFileFor(state) : null;
260
+ const file = state.active && state.stage === "user_turn" ? logFileFor(state) : null;
217
261
  if (file === watchedLogFile) return;
218
262
  unwatchLogFile();
219
263
  if (!file || !uiCtx?.hasUI) return;
220
264
  try {
221
265
  fs.watchFile(file, { interval: 1000 }, (curr, prev) => {
222
- if (!state.active || state.stage !== "awaiting_evidence") return;
266
+ if (!state.active || state.stage !== "user_turn") return;
223
267
  if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return;
224
268
  try {
225
269
  refreshUi();
@@ -254,6 +298,9 @@ export function registerDebugMode(pi: ExtensionAPI): void {
254
298
 
255
299
  pi.on("tool_call", async (event, ctx) => {
256
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" });
257
304
  if (event.toolName !== "edit" && event.toolName !== "write") return;
258
305
  const probes = probesInInput(event.input as Record<string, unknown>, ctx.cwd, currentRound(state).index);
259
306
  if (probes.length > 0) dispatch({ t: "probes_found", probes }, ctx);
@@ -261,29 +308,49 @@ export function registerDebugMode(pi: ExtensionAPI): void {
261
308
 
262
309
  // ============================== prompt injection ==============================
263
310
 
311
+ /** The blackboard plus the contract for the stage the session is in now. */
312
+ function injectedContext(session: DebugSession): string {
313
+ // Cleanup has no hypotheses left to form; the full methodology would only
314
+ // invite another round.
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.
264
327
  pi.on("before_agent_start", async () => {
265
- if (!state.active || state.stage === "awaiting_evidence") return;
328
+ if (!state.active) return;
266
329
  // The blackboard claims to be ground truth, so reconcile it with disk first.
267
330
  await syncLedger();
268
331
  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
332
  return {
273
333
  message: {
274
334
  customType: DEBUG_CONTEXT_TYPE,
275
- content: `${blackboard(state, describeEvidence(evidenceView(state)))}\n\n${contract}`,
335
+ content: injectedContext(state),
276
336
  display: false,
337
+ attribution: "agent",
277
338
  },
278
339
  };
279
340
  });
280
341
 
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.
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.
284
347
  pi.on("context", async (event) => {
285
- const filtered = keepLatestCustomType(event.messages, DEBUG_CONTEXT_TYPE);
286
- 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 };
287
354
  });
288
355
 
289
356
  // ============================== turn lifecycle ==============================
@@ -295,6 +362,13 @@ export function registerDebugMode(pi: ExtensionAPI): void {
295
362
  pi.on("message_end", async (event, ctx) => {
296
363
  if (!state.active) return;
297
364
  const msg = event.message as { role?: string; content?: unknown };
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;
371
+ }
298
372
  if (msg?.role !== "assistant") return;
299
373
  dispatch({ t: "assistant_message", text: extractAssistantText(msg.content) }, ctx);
300
374
  });
@@ -306,10 +380,63 @@ export function registerDebugMode(pi: ExtensionAPI): void {
306
380
  // disk, so the ledger is reconciled before the round may close.
307
381
  await syncLedger();
308
382
  const context = dispatch({ t: "turn_settled" }, ctx);
383
+ await setDebugToolsActive(state.active);
309
384
  if (context) return { continue: true, additionalContext: context };
310
385
  });
311
386
 
312
- registerDebugTools(pi, { getState: () => state, refreshLogCounts, readRunLines, syncLedger });
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
+ };
411
+ }
412
+
413
+ registerDebugTools(pi, {
414
+ getState: () => state,
415
+ refreshLogCounts,
416
+ readRunLines,
417
+ syncLedger,
418
+ handOff: applyHandoffRequest,
419
+ });
420
+
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 });
438
+ }
439
+ }
313
440
 
314
441
  // ============================== command guards ==============================
315
442
 
@@ -321,38 +448,52 @@ export function registerDebugMode(pi: ExtensionAPI): void {
321
448
  return state;
322
449
  }
323
450
 
324
- function ownsTurn(session: DebugSession): boolean {
325
- return session.stage === "awaiting_evidence" || session.stage === "open";
326
- }
327
-
328
451
  /**
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.
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.
332
459
  */
333
- async function claimTurn(ctx: ExtensionContext, action: string): Promise<boolean> {
334
- const session = activeSession(ctx);
335
- if (!session) return false;
336
- if (!ownsTurn(session)) {
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") {
337
465
  ctx.ui.notify(
338
- `debug-mode: the agent still has round ${currentRound(session).index} (stage: ${session.stage}) wait for it to stop.`,
466
+ `debug-mode: cleanup is still finishing reply to the agent, or use /${COMMAND_ABORT}.`,
339
467
  "error",
340
468
  );
341
- return false;
469
+ return null;
342
470
  }
343
- if (session.stage === "open" && ctx.hasUI) {
471
+ if (state.stage === "investigating") dispatch({ t: "reclaim" }, ctx);
472
+ return state.active && state.stage === "user_turn" ? state : null;
473
+ }
474
+
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) {
344
485
  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?`,
486
+ `${action} round ${round.index}?`,
487
+ `${describeHandoff(mode, round.index)} Continue with the evidence that already exists?`,
347
488
  );
348
- if (!confirmed) return false;
489
+ if (!confirmed) return null;
349
490
  }
350
- return true;
491
+ return state.active ? state : null;
351
492
  }
352
493
 
353
494
  // ============================== round transitions ==============================
354
495
 
355
- function startDebug(ctx: ExtensionContext, problem: string): void {
496
+ async function startDebug(ctx: ExtensionContext, problem: string): Promise<void> {
356
497
  const debugDir = debugDirFor(ctx.cwd, ctx.sessionManager.getSessionId());
357
498
  try {
358
499
  fs.mkdirSync(debugDir, { recursive: true });
@@ -362,23 +503,30 @@ export function registerDebugMode(pi: ExtensionAPI): void {
362
503
  ctx.ui.notify("debug-mode: could not create the debug log directory; debug mode was not started", "error");
363
504
  return;
364
505
  }
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");
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
+ );
368
512
  return;
369
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);
370
518
  dispatch({ t: "start", problem, debugDir, runId, logFile: path.join(debugDir, ACTIVE_LOG_FILE) }, ctx);
371
519
  }
372
520
 
373
- async function advanceDebug(ctx: ExtensionContext, userDetails?: string): Promise<void> {
374
- if (!(await claimTurn(ctx, "Proceed from"))) return;
521
+ async function advanceDebug(ctx: ExtensionCommandContext, userDetails?: string): Promise<void> {
522
+ if (!(await takeTurn(ctx, "/debug-proceed from"))) return;
375
523
  refreshLogCounts();
376
524
  if (!state.active) return;
377
525
  const closingRun = activeRunId(state);
378
526
  const logCount = closingRun ? (state.logCounts[closingRun] ?? 0) : 0;
379
527
  if (logCount === 0 && !userDetails && !hasNonProbeEvidence(currentRound(state)) && ctx.hasUI) {
380
528
  const confirmed = await ctx.ui.confirm(
381
- "Proceed without runtime logs?",
529
+ "/debug-proceed without runtime logs?",
382
530
  "No runtime observations were captured for this round. Continue to log analysis anyway?",
383
531
  );
384
532
  if (!confirmed) return;
@@ -387,35 +535,41 @@ export function registerDebugMode(pi: ExtensionAPI): void {
387
535
  const stillPending = pendingRequests(state);
388
536
  if (stillPending.length > 0 && ctx.hasUI) {
389
537
  const confirmed = await ctx.ui.confirm(
390
- "Proceed without all requested evidence?",
538
+ "/debug-proceed without all requested evidence?",
391
539
  `${stillPending.length} evidence request(s) still pending (${stillPending.map(r => r.id).join(", ")}). Continue anyway?`,
392
540
  );
393
541
  if (!confirmed) return;
394
542
  }
395
- if (!state.active || !ownsTurn(state) || !state.debugDir) return;
543
+ if (!state.active || state.stage !== "user_turn" || !state.debugDir) return;
396
544
 
397
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.
398
547
  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");
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
+ );
402
556
  return;
403
557
  }
404
- dispatch({ t: "proceed", runId, logCount, hypotheses, details: userDetails, now: Date.now() }, ctx);
558
+ dispatch({ t: "proceed", runId: created.runId, logCount, hypotheses, details: userDetails, now: Date.now() }, ctx);
405
559
  }
406
560
 
407
- async function markDebugFixed(ctx: ExtensionContext): Promise<void> {
408
- if (!(await claimTurn(ctx, "Mark as fixed from"))) return;
561
+ async function markDebugFixed(ctx: ExtensionCommandContext): Promise<void> {
562
+ if (!(await takeTurn(ctx, "/debug-done from"))) return;
409
563
  const logCount = currentLogCount();
410
564
  if (!state.active) return;
411
565
  if (logCount === 0 && !hasNonProbeEvidence(currentRound(state)) && ctx.hasUI) {
412
566
  const confirmed = await ctx.ui.confirm(
413
- "Mark as fixed without runtime logs?",
567
+ "/debug-done without runtime logs?",
414
568
  "No runtime observations were captured for this round. Mark the problem as fixed anyway?",
415
569
  );
416
570
  if (!confirmed) return;
417
571
  }
418
- if (!state.active || !ownsTurn(state)) return;
572
+ if (!state.active || state.stage !== "user_turn") return;
419
573
  dispatch({ t: "mark_fixed" }, ctx);
420
574
  }
421
575
 
@@ -430,27 +584,37 @@ export function registerDebugMode(pi: ExtensionAPI): void {
430
584
  }
431
585
  if (!state.active) return;
432
586
  dispatch({ t: "abort" }, ctx);
587
+ await setDebugToolsActive(false);
433
588
  }
434
589
 
435
590
  /**
436
591
  * Attach one user-provided evidence file to the current round. The file is
437
592
  * referenced in place — never copied, moved or deleted — and the action does
438
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.
439
598
  */
440
- async function attachEvidence(ctx: ExtensionContext, rawPath?: string, requestId: string | null = null): Promise<boolean> {
441
- const session = activeSession(ctx);
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);
442
603
  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");
604
+ const parsed = parseEvidenceArgument(args, session);
605
+ if (!parsed.ok) {
606
+ ctx.ui.notify(`debug-mode: ${parsed.error}`, "error");
445
607
  return false;
446
608
  }
447
- let input = rawPath?.trim() ?? "";
609
+ const requestId = parsed.requestId;
610
+ let input = parsed.rawPath?.trim() ?? "";
448
611
  if (!input) {
449
612
  if (!ctx.hasUI) {
450
- ctx.ui.notify(`Usage: /${COMMAND_EVIDENCE} <path>`, "error");
613
+ ctx.ui.notify(`Usage: /${COMMAND_EVIDENCE} <request-id|${UNLINKED_SELECTOR}> <path>`, "error");
451
614
  return false;
452
615
  }
453
- 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")) ?? "";
454
618
  if (!input.trim()) {
455
619
  ctx.ui.notify("debug-mode: no evidence file path provided", "error");
456
620
  return false;
@@ -502,7 +666,7 @@ export function registerDebugMode(pi: ExtensionAPI): void {
502
666
  );
503
667
  return;
504
668
  }
505
- startDebug(ctx, problem);
669
+ await startDebug(ctx, problem);
506
670
  },
507
671
  });
508
672
 
@@ -523,32 +687,12 @@ export function registerDebugMode(pi: ExtensionAPI): void {
523
687
  },
524
688
  });
525
689
 
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
690
  pi.registerCommand(COMMAND_EVIDENCE, {
547
- 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)),
548
693
  handler: async (args, ctx) => {
549
694
  uiCtx = ctx;
550
- const { requestId, rawPath } = parseEvidenceArgument(args);
551
- await attachEvidence(ctx, rawPath, requestId);
695
+ await attachEvidence(ctx, args);
552
696
  },
553
697
  });
554
698
 
@@ -588,7 +732,7 @@ export function registerDebugMode(pi: ExtensionAPI): void {
588
732
  });
589
733
  ctx.ui.notify(
590
734
  `debug-mode: stage=${session.stage} round=${round.index} run=${run ?? "(none)"}\n` +
591
- (round.openReason ? `${describeOpenReason(round.openReason, round.index)}\n` : "") +
735
+ (round.handoff ? `${describeHandoff(round.handoff, round.index, round.plan === null)}\n` : "") +
592
736
  `${describeLedger(scan)}\n` +
593
737
  `logs: ${session.runHistory.map(r => `${r}=${session.logCounts[r] ?? 0}`).join(", ") || "(none)"}\n` +
594
738
  `this run by hypothesis: ${describeHypotheses(tallies)}\n` +
@@ -638,12 +782,16 @@ export function registerDebugMode(pi: ExtensionAPI): void {
638
782
  }
639
783
  refreshLogCounts();
640
784
  if (state.active) {
785
+ const round = currentRound(state);
641
786
  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}.`,
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}.`,
643
790
  "info",
644
791
  );
645
792
  }
646
793
  }
794
+ await setDebugToolsActive(state.active);
647
795
  refreshUi();
648
796
  watchLogFile();
649
797
  });