@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/CHANGELOG.md +82 -48
- package/README.md +88 -31
- package/package.json +41 -41
- package/src/debug-mode.ts +285 -92
- package/src/evidence.ts +325 -169
- package/src/gate.ts +115 -51
- package/src/log-files.ts +161 -115
- package/src/machine.ts +136 -49
- package/src/main.ts +24 -24
- package/src/methodology.ts +56 -15
- package/src/state.ts +191 -42
- package/src/tools.ts +195 -9
- package/src/ui.ts +145 -71
package/src/debug-mode.ts
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
import { Text } from "@oh-my-pi/pi-coding-agent";
|
|
2
|
-
import type {
|
|
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 {
|
|
6
|
-
|
|
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
|
-
*
|
|
76
|
-
* without re-entering the UI refresh that asked for
|
|
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(
|
|
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
|
|
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 === "
|
|
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 !== "
|
|
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
|
|
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:
|
|
337
|
+
content: injectedContext(state),
|
|
276
338
|
display: false,
|
|
339
|
+
attribution: "agent",
|
|
277
340
|
},
|
|
278
341
|
};
|
|
279
342
|
});
|
|
280
343
|
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
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
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
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
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (!
|
|
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:
|
|
468
|
+
`debug-mode: cleanup is still finishing — reply to the agent, or use /${COMMAND_ABORT}.`,
|
|
339
469
|
"error",
|
|
340
470
|
);
|
|
341
|
-
return
|
|
471
|
+
return null;
|
|
342
472
|
}
|
|
343
|
-
if (
|
|
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}
|
|
346
|
-
`${
|
|
488
|
+
`${action} round ${round.index}?`,
|
|
489
|
+
`${describeHandoff(mode, round.index)} Continue with the evidence that already exists?`,
|
|
347
490
|
);
|
|
348
|
-
if (!confirmed) return
|
|
491
|
+
if (!confirmed) return null;
|
|
349
492
|
}
|
|
350
|
-
return
|
|
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
|
|
366
|
-
if (
|
|
367
|
-
ctx.ui.notify(
|
|
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:
|
|
374
|
-
if (!(await
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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 ||
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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:
|
|
408
|
-
if (!(await
|
|
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
|
-
"
|
|
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 ||
|
|
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:
|
|
441
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
});
|