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