@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/state.ts CHANGED
@@ -5,12 +5,11 @@ export const DEBUG_ENTRY = "com.omp.debug-mode.state";
5
5
  export const DEBUG_CONTEXT_TYPE = "debug-mode-context";
6
6
 
7
7
  /**
8
- * Whose move it is. `investigating` is the agent's turn; every other stage
9
- * belongs to the user. `open` exists because a round that settled without
10
- * closing still needs a reply, and that is not the same as the reproduction
11
- * gate — conflating the two is what makes an unclosed round look like a pause.
8
+ * Whose move it is, and nothing else. There is exactly one user stage: a turn
9
+ * the agent has handed back is the user's regardless of how well the agent
10
+ * closed the round, so command legality never depends on protocol compliance.
12
11
  */
13
- export type Stage = "investigating" | "open" | "awaiting_evidence" | "cleaning_up";
12
+ export type Stage = "investigating" | "user_turn" | "cleaning_up";
14
13
 
15
14
  export type EvidenceMethod = "agent_inspection" | "runtime_probe" | "user_report" | "user_artifact";
16
15
 
@@ -54,8 +53,12 @@ export interface EvidenceObservation {
54
53
  addedAt: number;
55
54
  }
56
55
 
57
- /** Why a settled round is open instead of gated. Drives the `open` widget. */
58
- export type OpenReason = "awaiting_reply" | "probes_missing" | "unclosed";
56
+ /**
57
+ * How the agent handed the turn back. This is wording and guidance only: every
58
+ * mode is the same `user_turn` stage and allows the same commands, so a mode
59
+ * the agent got wrong can never make a legitimate command illegal.
60
+ */
61
+ export type HandoffMode = "reproduce" | "capture" | "question" | "incomplete";
59
62
 
60
63
  /**
61
64
  * One evidence round. Round membership is structural: everything scoped to a
@@ -70,9 +73,17 @@ export interface Round {
70
73
  reproductionSteps: string[];
71
74
  /** Ledger ids of probes introduced while this round was investigating. */
72
75
  probeIds: string[];
73
- /** Separate budgets: a probe nudge must not consume the closing-tag budget. */
74
- nudges: { tags: number; probes: number };
75
- openReason: OpenReason | null;
76
+ /** Separate budgets: a probe nudge must not consume the closing one. */
77
+ nudges: { handoff: number; probes: number };
78
+ /**
79
+ * A reminder went out and no tool call has landed since. One reminder per
80
+ * round of visible progress: a model that answers every reminder with more
81
+ * prose would otherwise spend the whole budget and delay the user by three
82
+ * turns to reach the same handoff.
83
+ */
84
+ awaitingProgress: boolean;
85
+ /** Set while the round sits with the user; null while the agent has the turn. */
86
+ handoff: HandoffMode | null;
76
87
  }
77
88
 
78
89
  export interface DebugSession {
@@ -110,8 +121,9 @@ export function blankRound(index: number, runId: string | null): Round {
110
121
  plan: null,
111
122
  reproductionSteps: [],
112
123
  probeIds: [],
113
- nudges: { tags: 0, probes: 0 },
114
- openReason: null,
124
+ nudges: { handoff: 0, probes: 0 },
125
+ awaitingProgress: false,
126
+ handoff: null,
115
127
  };
116
128
  }
117
129
 
@@ -143,8 +155,19 @@ export function logFileFor(session: DebugSession, run: string | null = activeRun
143
155
  return resolveRunLogFile(session.debugDir, run, activeRunId(session));
144
156
  }
145
157
 
158
+ /**
159
+ * Every probe the ledger still finds on disk, session-wide. Instrumentation
160
+ * deliberately outlives the round that installed it — the methodology keeps
161
+ * probes in place across a fix so the verification round can reuse them — so a
162
+ * round-scoped count would report "nothing instrumented" on exactly the rounds
163
+ * that are best instrumented.
164
+ */
165
+ export function liveProbeCount(session: DebugSession): number {
166
+ return session.probes.length;
167
+ }
168
+
146
169
  /** Probes this round introduced that are still in the ledger (i.e. still on disk). */
147
- export function liveProbeIds(session: DebugSession, round: Round = currentRound(session)): string[] {
170
+ export function roundProbeIds(session: DebugSession, round: Round = currentRound(session)): string[] {
148
171
  return round.probeIds.filter(id => session.probes.some(probe => probe.id === id));
149
172
  }
150
173
 
@@ -152,6 +175,13 @@ export function declaresRuntimeProbe(round: Round): boolean {
152
175
  return (round.plan ?? []).some(request => request.method === "runtime_probe");
153
176
  }
154
177
 
178
+ /** The plan asks the user to report or capture something themselves. */
179
+ export function needsUserCapture(round: Round): boolean {
180
+ return (round.plan ?? []).some(
181
+ request => request.method === "user_report" || request.method === "user_artifact",
182
+ );
183
+ }
184
+
155
185
  /** Requests whose method is satisfied by the agent alone, so they never gate on the user. */
156
186
  export function hasNonProbeEvidence(round: Round): boolean {
157
187
  return (round.plan ?? []).some(request => request.method !== "runtime_probe");
@@ -258,26 +288,64 @@ export function resolveRun(
258
288
  }
259
289
 
260
290
  /**
261
- * Keep only the newest copy of a custom-type injection. `before_agent_start`
262
- * re-injects a fresh blackboard every turn; `context` runs afterwards on the
263
- * combined history, so deleting every match would drop the copy that was just
264
- * added and the model would never see it.
291
+ * Leave the context holding exactly one, current copy of a custom-type
292
+ * injection: drop every stale copy and rewrite the survivor's content.
293
+ *
294
+ * Both halves are load-bearing. `before_agent_start` appends a fresh copy every
295
+ * prompt, so keeping all of them would grow the context without bound — but it
296
+ * does not fire at all when the host resumes the agent loop itself, so a turn
297
+ * started by a reminder or a nudge would otherwise read whatever the previous
298
+ * turn was told. Rewriting here, where the request is actually assembled, is
299
+ * the only point that no path into the model can skip.
300
+ *
301
+ * Returns null when the context already says the right thing, so an unchanged
302
+ * request is passed through untouched.
265
303
  */
266
- export function keepLatestCustomType<M extends { role?: string; customType?: string }>(
304
+ export function syncCustomType<M extends { role?: string; customType?: string; content?: unknown }>(
267
305
  messages: readonly M[],
268
306
  customType: string,
269
- ): M[] {
307
+ content: string | null,
308
+ ): M[] | null {
309
+ const isCopy = (message: M | undefined): boolean =>
310
+ message?.role === "custom" && message.customType === customType;
270
311
  let last = -1;
312
+ let copies = 0;
313
+ for (let i = 0; i < messages.length; i++) {
314
+ if (!isCopy(messages[i])) continue;
315
+ last = i;
316
+ copies++;
317
+ }
318
+ if (last < 0) return null;
319
+ const rewrite = content !== null && messages[last]?.content !== content;
320
+ if (copies === 1 && !rewrite) return null;
321
+ const kept: M[] = [];
271
322
  for (let i = 0; i < messages.length; i++) {
272
- const message = messages[i];
273
- if (message?.role === "custom" && message.customType === customType) last = i;
323
+ const message = messages[i] as M;
324
+ if (!isCopy(message)) {
325
+ kept.push(message);
326
+ continue;
327
+ }
328
+ if (i !== last) continue;
329
+ kept.push(rewrite ? ({ ...message, content } as M) : message);
274
330
  }
275
- if (last < 0) return [...messages];
276
- return messages.filter((message, index) => !(message.role === "custom" && message.customType === customType && index !== last));
331
+ return kept;
277
332
  }
278
333
 
279
- const STAGES: readonly Stage[] = ["investigating", "open", "awaiting_evidence", "cleaning_up"];
280
- const OPEN_REASONS: readonly OpenReason[] = ["awaiting_reply", "probes_missing", "unclosed"];
334
+ const STAGES: readonly Stage[] = ["investigating", "user_turn", "cleaning_up"];
335
+ const HANDOFF_MODES: readonly HandoffMode[] = ["reproduce", "capture", "question", "incomplete"];
336
+
337
+ /** Pre-`user_turn` stage names and the handoff each one implied. */
338
+ const LEGACY_STAGES: Readonly<Record<string, { stage: Stage; handoff: HandoffMode | null }>> = {
339
+ open: { stage: "user_turn", handoff: null },
340
+ awaiting_evidence: { stage: "user_turn", handoff: "reproduce" },
341
+ };
342
+
343
+ /** Pre-`handoff` reasons, mapped onto the mode that carries the same guidance. */
344
+ const LEGACY_HANDOFFS: Readonly<Record<string, HandoffMode>> = {
345
+ awaiting_reply: "question",
346
+ probes_missing: "incomplete",
347
+ unclosed: "incomplete",
348
+ };
281
349
 
282
350
  function asArray<T>(value: unknown): T[] {
283
351
  return Array.isArray(value) ? (value as T[]) : [];
@@ -287,6 +355,12 @@ function asRecord(value: unknown): Record<string, unknown> {
287
355
  return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
288
356
  }
289
357
 
358
+ function reviveHandoff(raw: unknown): HandoffMode | null {
359
+ if (typeof raw !== "string") return null;
360
+ if (HANDOFF_MODES.includes(raw as HandoffMode)) return raw as HandoffMode;
361
+ return LEGACY_HANDOFFS[raw] ?? null;
362
+ }
363
+
290
364
  function reviveRound(raw: unknown, fallbackIndex: number): Round {
291
365
  const record = asRecord(raw);
292
366
  const nudges = asRecord(record.nudges);
@@ -297,18 +371,26 @@ function reviveRound(raw: unknown, fallbackIndex: number): Round {
297
371
  reproductionSteps: asArray<string>(record.reproductionSteps),
298
372
  probeIds: asArray<string>(record.probeIds),
299
373
  nudges: {
300
- tags: typeof nudges.tags === "number" ? nudges.tags : 0,
301
- probes: typeof nudges.probes === "number" ? nudges.probes : 0,
374
+ // `tags` is the pre-tool budget name, when prose was still a closure.
375
+ handoff: numberOr(nudges.handoff, numberOr(nudges.tags, 0)),
376
+ probes: numberOr(nudges.probes, 0),
302
377
  },
303
- openReason: OPEN_REASONS.includes(record.openReason as OpenReason) ? (record.openReason as OpenReason) : null,
378
+ // Never restored: no agent turn survives a restart, so nothing is pending.
379
+ awaitingProgress: false,
380
+ handoff: reviveHandoff(record.handoff ?? record.openReason),
304
381
  };
305
382
  }
306
383
 
384
+ function numberOr(value: unknown, fallback: number): number {
385
+ return typeof value === "number" ? value : fallback;
386
+ }
387
+
307
388
  /**
308
- * Rebuild persisted state, tolerating the pre-`rounds` layout so a session that
309
- * was mid-debug across an upgrade keeps working. An unrecognised stage resolves
310
- * to `open`: the conservative reading is that the agent owes the user something,
311
- * not that the user owes a reproduction.
389
+ * Rebuild persisted state, tolerating the pre-`user_turn` stage names and the
390
+ * pre-`rounds` layout so a session that was mid-debug across an upgrade keeps
391
+ * working. Anything unrecognised including a persisted `investigating`, since
392
+ * no agent turn survives a restore — resolves to `user_turn`, because the user
393
+ * must always be able to act on a restored investigation.
312
394
  */
313
395
  export function reviveState(data: unknown): DebugState {
314
396
  const record = asRecord(data);
@@ -317,21 +399,23 @@ export function reviveState(data: unknown): DebugState {
317
399
  const debugDir = typeof record.debugDir === "string" ? record.debugDir : null;
318
400
  const probes = asArray<Probe>(record.probes);
319
401
  const logCounts = asRecord(record.logCounts) as Record<string, number>;
320
- const stageRaw = typeof record.stage === "string" ? record.stage : legacyStage(record.phase);
321
- const stage = STAGES.includes(stageRaw as Stage) ? (stageRaw as Stage) : "open";
402
+ const restored = restoreStage(record);
322
403
 
323
404
  const rounds = Array.isArray(record.rounds)
324
405
  ? record.rounds.map((round, i) => reviveRound(round, i + 1))
325
406
  : [legacyRound(record, probes)];
407
+ const resolved = rounds.length > 0 ? rounds : [blankRound(1, null)];
408
+ const last = resolved[resolved.length - 1] as Round;
409
+ if (restored.stage === "user_turn" && last.handoff === null) {
410
+ resolved[resolved.length - 1] = { ...last, handoff: restored.handoff ?? "incomplete" };
411
+ }
326
412
 
327
413
  return {
328
414
  active: true,
329
- // No agent turn survives a restore, so a persisted `investigating` stage
330
- // would strand the user waiting for a turn that will never resume.
331
- stage: stage === "investigating" ? "open" : stage,
415
+ stage: restored.stage,
332
416
  problem,
333
417
  debugDir,
334
- rounds: rounds.length > 0 ? rounds : [blankRound(1, null)],
418
+ rounds: resolved,
335
419
  probes,
336
420
  runHistory: asArray<string>(record.runHistory),
337
421
  logCounts,
@@ -342,7 +426,18 @@ export function reviveState(data: unknown): DebugState {
342
426
  };
343
427
  }
344
428
 
345
- function legacyStage(phase: unknown): Stage {
429
+ function restoreStage(record: Record<string, unknown>): { stage: Stage; handoff: HandoffMode | null } {
430
+ const raw = typeof record.stage === "string" ? record.stage : legacyPhase(record.phase);
431
+ const legacy = LEGACY_STAGES[raw];
432
+ if (legacy) return legacy;
433
+ // A persisted `investigating` cannot be resumed: no agent turn survives a
434
+ // restore, so honouring it would strand the user with every command refused.
435
+ if (raw !== "investigating" && STAGES.includes(raw as Stage)) return { stage: raw as Stage, handoff: null };
436
+ return { stage: "user_turn", handoff: null };
437
+ }
438
+
439
+ /** Collapse the original `phase` field onto the stage name it became. */
440
+ function legacyPhase(phase: unknown): string {
346
441
  if (phase === "waiting") return "awaiting_evidence";
347
442
  if (phase === "cleanup") return "cleaning_up";
348
443
  return "open";
@@ -360,11 +455,63 @@ function legacyRound(record: Record<string, unknown>, probes: readonly Probe[]):
360
455
  plan: requests.length > 0 ? requests.map(({ round: _round, ...rest }) => rest) : null,
361
456
  reproductionSteps: asArray<string>(record.reproductionSteps),
362
457
  probeIds: probes.filter(probe => probe.round === index).map(probe => probe.id),
363
- nudges: { tags: typeof record.gateNudges === "number" ? record.gateNudges : 0, probes: 0 },
364
- openReason: null,
458
+ nudges: { handoff: numberOr(record.gateNudges, 0), probes: 0 },
459
+ awaitingProgress: false,
460
+ handoff: null,
365
461
  };
366
462
  }
367
463
 
464
+ /** The closing requirements of the current round, as observed facts. */
465
+ export function roundFacts(session: DebugSession, round: Round = currentRound(session)): string {
466
+ const plan = round.plan ? `${round.plan.length} request(s)` : "MISSING";
467
+ const steps = round.reproductionSteps.length > 0 ? `${round.reproductionSteps.length} step(s)` : "MISSING";
468
+ return `evidence plan: ${plan}, reproduction steps: ${steps}, live probes: ${liveProbeCount(session)}`;
469
+ }
470
+
471
+ /**
472
+ * Whose turn it is and what closing the round still requires, stated to the
473
+ * model. The model drives these transitions, so leaving the stage out is what
474
+ * lets it announce a reproduction while the round is still its own.
475
+ */
476
+ export function stageBriefing(session: DebugSession): string {
477
+ const round = currentRound(session);
478
+ const facts = `Round ${round.index} facts — ${roundFacts(session, round)}.`;
479
+ if (session.stage === "cleaning_up") {
480
+ return `Turn owner: AGENT (cleanup). Remove every probe, verify the ledger is empty, then summarize. ${facts}`;
481
+ }
482
+ if (session.stage === "user_turn") {
483
+ // Written to be true whichever way this turn started. The context is
484
+ // assembled before a user message is announced, so this text cannot know
485
+ // which case it is in — but the model can see its own transcript, so the
486
+ // test it is given is one it can actually apply.
487
+ //
488
+ // Three triggers land here, and only the first one is the user's: a reply,
489
+ // the request that follows the `hand_off_to_user` call in the same turn
490
+ // (a tool call never ends a turn, so this one is unavoidable), and a host
491
+ // continuation. The last two need the same answer, so they are described
492
+ // together rather than as a list of causes to match against.
493
+ return (
494
+ `Turn owner: USER. Round ${round.index} was handed back as "${round.handoff ?? "incomplete"}" and is NOT closed. ${facts} ` +
495
+ "The user has not run /debug-proceed. Check what started this turn before you do anything else. " +
496
+ "If a user message is part of it, the round is yours again: answer it, then call hand_off_to_user to " +
497
+ "re-close the round — never assume the earlier handoff still stands. " +
498
+ "If nothing in this turn came from the user, nothing has changed since the handoff: you are either " +
499
+ "finishing the turn in which you just called hand_off_to_user, or you were resumed by a reminder or " +
500
+ "another automatic continuation. Either way the user has not acted, no reproduction has run, and no new " +
501
+ "observation exists. Do not read logs, do not analyze, do not resume the plan. Write one short line " +
502
+ "addressed to the user — what to do now, and that /debug-proceed comes after they reproduce (or " +
503
+ "/debug-done if the bug is already fixed) — then end the turn. Never report " +
504
+ "that you were reminded and never restate this briefing back to them."
505
+ );
506
+ }
507
+ return (
508
+ `Turn owner: AGENT. Round ${round.index} is yours and you must close it before you stop. ${facts} ` +
509
+ "Close it by calling hand_off_to_user: that call is what moves the session into the user's hands. " +
510
+ "Prose telling the user to reproduce does not change the state, and a turn that stops without that call is " +
511
+ "sent straight back to you to make it."
512
+ );
513
+ }
514
+
368
515
  export function blackboard(session: DebugSession, evidenceDescription = "(none)"): string {
369
516
  const round = currentRound(session);
370
517
  const probes = session.probes.map(p => `${p.id} (${p.file}, round ${p.round})`).join("\n ") || "(none)";
@@ -372,7 +519,9 @@ export function blackboard(session: DebugSession, evidenceDescription = "(none)"
372
519
  .map(([run, n]) => `${run}: ${n}`)
373
520
  .join(", ") || "(none yet)";
374
521
  return `\
375
- [DEBUG MODE ACTIVE — round ${round.index}]
522
+ [DEBUG MODE ACTIVE — round ${round.index} · stage ${session.stage}]
523
+
524
+ ${stageBriefing(session)}
376
525
 
377
526
  Problem under investigation:
378
527
  ${session.problem}
package/src/tools.ts CHANGED
@@ -1,12 +1,16 @@
1
- import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
2
- import { describeEvidence } from "./evidence";
3
- import { describeHypotheses, summarizeHypotheses } from "./log-files";
1
+ import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
2
+ import { describeEvidence, validateEvidenceRequests } from "./evidence";
3
+ import { type HypothesisTally, describeHypotheses, summarizeHypotheses } from "./log-files";
4
+ import { HANDOFF_TOOL } from "./methodology";
4
5
  import { type LedgerScan, describeLedger } from "./probes";
5
6
  import {
6
7
  type DebugState,
8
+ type EvidenceRequest,
7
9
  type EvidenceView,
10
+ type HandoffMode,
8
11
  activeRunId,
9
12
  allRequests,
13
+ currentRound,
10
14
  logFileFor,
11
15
  resolveRun,
12
16
  } from "./state";
@@ -23,19 +27,185 @@ function noMatch(kind: string, id: string, available: readonly string[]): string
23
27
  return `No ${kind} matches ${JSON.stringify(id)}. Known ${kind}s: ${list}.`;
24
28
  }
25
29
 
30
+ /** An explicit round closure, already validated against the ledger contract. */
31
+ export interface HandoffRequest {
32
+ mode: Exclude<HandoffMode, "incomplete">;
33
+ steps: string[];
34
+ plan: EvidenceRequest[] | null;
35
+ }
36
+
37
+ export type HandoffOutcome = { ok: true; summary: string } | { ok: false; error: string };
38
+
39
+ /** The four tools this package registers. Named so activation can add/remove them as a set. */
40
+ export const DEBUG_TOOL_NAMES = [
41
+ HANDOFF_TOOL,
42
+ "get_debug_logs",
43
+ "list_debug_probes",
44
+ "list_debug_evidence",
45
+ ] as const;
46
+
47
+ export type DebugToolName = (typeof DEBUG_TOOL_NAMES)[number];
48
+
49
+ /**
50
+ * The enabled-tool list that should follow debug-mode's session flag.
51
+ * `null` means the current set already matches, so the host must not be poked.
52
+ */
53
+ export function nextActiveTools(current: readonly string[], debugActive: boolean): string[] | null {
54
+ const wanted: readonly string[] = DEBUG_TOOL_NAMES;
55
+ if (debugActive) {
56
+ if (wanted.every(name => current.includes(name))) return null;
57
+ return [...new Set([...current, ...wanted])];
58
+ }
59
+ if (wanted.every(name => !current.includes(name))) return null;
60
+ return current.filter(name => !wanted.includes(name));
61
+ }
62
+
26
63
  export interface DebugToolDeps {
27
64
  getState(): DebugState;
28
65
  refreshLogCounts(): void;
29
66
  readRunLines(run: string): string[];
30
67
  /** Rescan the probe ledger and fold the result back into session state. */
31
68
  syncLedger(): Promise<LedgerScan>;
69
+ /** Apply an explicit handoff; returns the reason when the machine refuses. */
70
+ handOff(request: HandoffRequest, ctx: ExtensionContext): HandoffOutcome;
71
+ }
72
+
73
+ /**
74
+ * Discoverable tools are removed from the top-level schema and only surfaced
75
+ * through tool search, so a tool the injected contract orders by name — above
76
+ * all the handoff, which is the only way a round reaches the user — has to be
77
+ * essential once it is active. `defaultInactive` keeps them out of ordinary
78
+ * sessions: they are registered at plugin load, but the host does not put them
79
+ * in the model's schema until `/debug-mode` starts (or a resumed session is
80
+ * already in debug mode). Both fields reached `ToolDefinition` after the
81
+ * version this package type-checks against, hence the spread instead of
82
+ * inline keys.
83
+ */
84
+ const SESSION_TOOL: { loadMode?: "essential"; defaultInactive?: boolean } = {
85
+ loadMode: "essential",
86
+ defaultInactive: true,
87
+ };
88
+
89
+ /** Named so every `get_debug_logs` exit reports the same details shape. */
90
+ interface DebugLogDetails {
91
+ run: string | null;
92
+ file: string | null;
93
+ count: number;
94
+ hypotheses?: HypothesisTally[];
95
+ }
96
+
97
+ interface RejectedCall {
98
+ content: { type: "text"; text: string }[];
99
+ isError: true;
100
+ }
101
+
102
+ /**
103
+ * Refuse to read logs while the round is with the user. Stated as a fact about
104
+ * the world rather than a permission error, because the model's mistake is
105
+ * believing a reproduction already ran, not believing it is allowed to look.
106
+ */
107
+ function userTurnRefusal(round: number): string {
108
+ return (
109
+ `Round ${round} is still with the user and they have not run /debug-proceed, so no reproduction has run and ` +
110
+ "nothing has been captured since the handoff. This turn was not started by the user — it is a reminder or " +
111
+ "an automatic continuation. Do not analyze anything: say you are still waiting for the user and end the turn."
112
+ );
113
+ }
114
+
115
+ /**
116
+ * A refused handoff must read as a repair instruction, not as a failure: the
117
+ * agent still holds the turn and can fix the call before it stops.
118
+ */
119
+ function reject(reason: string): RejectedCall {
120
+ return {
121
+ content: [{ type: "text", text: `Handoff rejected — ${reason} Nothing was recorded; the round is still yours.` }],
122
+ isError: true,
123
+ };
32
124
  }
33
125
 
34
126
  export function registerDebugTools(pi: ExtensionAPI, deps: DebugToolDeps): void {
35
127
  const z = pi.zod;
36
- const { getState, refreshLogCounts, readRunLines, syncLedger } = deps;
128
+ const { getState, refreshLogCounts, readRunLines, syncLedger, handOff } = deps;
129
+
130
+ const planEntry = z.object({
131
+ id: z.string().describe("Request id, unique within the plan (e.g. E1)"),
132
+ hypothesisIds: z.array(z.string()).describe("Every hypothesis this single evidence action settles"),
133
+ method: z
134
+ .enum(["agent_inspection", "runtime_probe", "user_report", "user_artifact"])
135
+ .describe("Cheapest reliable method, in that priority order"),
136
+ title: z.string().describe("Short title for the request"),
137
+ rationale: z
138
+ .string()
139
+ .describe(
140
+ "Why this method is decisive; for user_report/user_artifact, why BOTH autonomous inspection and model-added probes cannot answer it",
141
+ ),
142
+ instructions: z.array(z.string()).describe("Actionable capture/report steps"),
143
+ artifactHint: z.string().optional().describe("Expected file kind; only for user_artifact"),
144
+ });
145
+
146
+ pi.registerTool({
147
+ ...SESSION_TOOL,
148
+ name: HANDOFF_TOOL,
149
+ label: "Hand Off To User",
150
+ description:
151
+ "Close the current debug round and hand the session to the user. This call — not your prose — is what moves debug mode into the user's turn: it renders the steps in the user's widget, makes /debug-proceed available, and records the evidence plan. Call it as the last action of every round. An invalid argument is rejected with the exact problem so you can fix it without ending the turn.",
152
+ parameters: z.object({
153
+ mode: z
154
+ .enum(["reproduce", "capture", "question"])
155
+ .describe(
156
+ '"reproduce": the user must run the app so the probes record. "capture": the user only supplies a report or a file. "question": you need an answer before you can plan.',
157
+ ),
158
+ steps: z
159
+ .array(z.string())
160
+ .optional()
161
+ .describe(
162
+ "Numbered actions the USER performs now (reproduce, capture, restart), one per entry. Never include agent work such as installing probes, reading logs or analyzing results. Required unless mode is question.",
163
+ ),
164
+ plan: z
165
+ .array(planEntry)
166
+ .optional()
167
+ .describe(
168
+ "Evidence plan covering EVERY open hypothesis. Required unless mode is question or the round already recorded a plan.",
169
+ ),
170
+ }),
171
+ // Deliberately the cheapest tier: the call touches no file and no command,
172
+ // only who owns the turn. On the `write` tier an approval policy can put a
173
+ // prompt in front of the one action every round is required to end with,
174
+ // and a declined or interrupted prompt is indistinguishable from a model
175
+ // that never called it.
176
+ approval: "read",
177
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
178
+ const state = getState();
179
+ if (!state.active) return { content: [{ type: "text", text: INACTIVE_TEXT }], isError: true };
180
+ const mode = params.mode as HandoffRequest["mode"];
181
+ const steps = (params.steps ?? []).map(step => step.trim()).filter(step => step.length > 0);
182
+ if (mode !== "question" && steps.length === 0) {
183
+ return reject(`mode "${mode}" needs steps: list what the user does now, one action per entry.`);
184
+ }
185
+
186
+ let plan: EvidenceRequest[] | null = null;
187
+ if (params.plan !== undefined) {
188
+ const validation = validateEvidenceRequests(params.plan);
189
+ if (!validation.ok) return reject(`${validation.error}.`);
190
+ plan = validation.requests;
191
+ }
192
+ if (mode !== "question" && plan === null && currentRound(state).plan === null) {
193
+ return reject(
194
+ `mode "${mode}" needs plan: this round has no evidence plan yet, so nothing would link what the user captures to a hypothesis.`,
195
+ );
196
+ }
197
+
198
+ const outcome = handOff({ mode, steps, plan }, ctx);
199
+ if (!outcome.ok) return reject(outcome.error);
200
+ return {
201
+ content: [{ type: "text", text: outcome.summary }],
202
+ details: { mode, steps: steps.length, plan: plan?.length ?? 0 },
203
+ };
204
+ },
205
+ });
37
206
 
38
207
  pi.registerTool({
208
+ ...SESSION_TOOL,
39
209
  name: "get_debug_logs",
40
210
  label: "Get Debug Logs",
41
211
  description:
@@ -49,14 +219,25 @@ export function registerDebugTools(pi: ExtensionAPI, deps: DebugToolDeps): void
49
219
  async execute(_toolCallId, params) {
50
220
  refreshLogCounts();
51
221
  const state = getState();
222
+ const noRun: DebugLogDetails = { run: null, file: null, count: 0 };
52
223
  if (!state.active) {
53
- return { content: [{ type: "text", text: INACTIVE_TEXT }], details: { run: null, file: null, count: 0 } };
224
+ return { content: [{ type: "text", text: INACTIVE_TEXT }], details: noRun };
225
+ }
226
+ // The round is with the user, so this turn cannot be one they started:
227
+ // a reply flips the stage back before any tool can run. Reading logs
228
+ // here would analyze a reproduction that has not happened yet.
229
+ if (state.stage === "user_turn") {
230
+ return {
231
+ content: [{ type: "text", text: userTurnRefusal(currentRound(state).index) }],
232
+ details: noRun,
233
+ isError: true,
234
+ };
54
235
  }
55
236
  const selection = resolveRun(params, state.runHistory, activeRunId(state), state.logCounts);
56
237
  if (!selection.run) {
57
238
  return {
58
239
  content: [{ type: "text", text: `(${selection.note ?? "no debug run is available"})` }],
59
- details: { run: null, file: null, count: 0 },
240
+ details: noRun,
60
241
  };
61
242
  }
62
243
 
@@ -79,14 +260,18 @@ export function registerDebugTools(pi: ExtensionAPI, deps: DebugToolDeps): void
79
260
  const body =
80
261
  lines.join("\n") ||
81
262
  "(no logs captured — the instrumented path may not have executed, the build may be stale, the path may be wrong, or the file append may have failed)";
82
- return {
83
- content: [{ type: "text", text: header + body }],
84
- details: { run, file: logFileFor(state, run), count: lines.length, hypotheses: tallies },
263
+ const details: DebugLogDetails = {
264
+ run,
265
+ file: logFileFor(state, run),
266
+ count: lines.length,
267
+ hypotheses: tallies,
85
268
  };
269
+ return { content: [{ type: "text", text: header + body }], details };
86
270
  },
87
271
  });
88
272
 
89
273
  pi.registerTool({
274
+ ...SESSION_TOOL,
90
275
  name: "list_debug_probes",
91
276
  label: "List Debug Probes",
92
277
  description:
@@ -104,6 +289,7 @@ export function registerDebugTools(pi: ExtensionAPI, deps: DebugToolDeps): void
104
289
  });
105
290
 
106
291
  pi.registerTool({
292
+ ...SESSION_TOOL,
107
293
  name: "list_debug_evidence",
108
294
  label: "List Debug Evidence",
109
295
  description: