@siuver/omp-debug-mode 0.1.4 → 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 -35
- package/README.md +98 -24
- package/package.json +41 -41
- package/src/debug-mode.ts +463 -409
- package/src/evidence.ts +325 -196
- package/src/gate.ts +138 -45
- package/src/log-files.ts +154 -115
- package/src/machine.ts +421 -0
- package/src/main.ts +24 -24
- package/src/methodology.ts +74 -17
- package/src/probes.ts +9 -9
- package/src/state.ts +392 -84
- package/src/tools.ts +232 -30
- package/src/ui.ts +120 -56
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,
|
|
@@ -12,32 +24,28 @@ import {
|
|
|
12
24
|
readJsonlLines,
|
|
13
25
|
summarizeHypotheses,
|
|
14
26
|
} from "./log-files";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
METHODOLOGY,
|
|
19
|
-
PROCEED_REMINDER,
|
|
20
|
-
buildFixedMessage,
|
|
21
|
-
buildProceedMessage,
|
|
22
|
-
buildStartMessage,
|
|
23
|
-
extractAssistantText,
|
|
24
|
-
extractReproductionSteps,
|
|
25
|
-
} from "./methodology";
|
|
27
|
+
import { type DebugEvent, type Effect, PROMPT_FIXED, PROMPT_PROCEED, PROMPT_START, reduce } from "./machine";
|
|
28
|
+
import { CLEANUP_CONTRACT, METHODOLOGY, extractAssistantText } from "./methodology";
|
|
29
|
+
import { type LedgerScan, describeLedger, probesInInput, scanLedger, survivingProbes } from "./probes";
|
|
26
30
|
import {
|
|
31
|
+
type DebugSession,
|
|
32
|
+
type DebugState,
|
|
27
33
|
DEBUG_CONTEXT_TYPE,
|
|
28
34
|
DEBUG_ENTRY,
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
INACTIVE,
|
|
36
|
+
activeRunId,
|
|
31
37
|
blackboard,
|
|
32
38
|
compareRunIds,
|
|
39
|
+
currentRound,
|
|
33
40
|
evidenceSummary,
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
evidenceView,
|
|
42
|
+
hasNonProbeEvidence,
|
|
36
43
|
logFileFor,
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
pendingRequests,
|
|
45
|
+
reviveState,
|
|
46
|
+
syncCustomType,
|
|
39
47
|
} from "./state";
|
|
40
|
-
import { registerDebugTools } from "./tools";
|
|
48
|
+
import { type HandoffOutcome, type HandoffRequest, nextActiveTools, registerDebugTools } from "./tools";
|
|
41
49
|
import { applyUi } from "./ui";
|
|
42
50
|
import { debugDirFor, excludeDebugLogsFromGit, pruneDebugRoot } from "./workspace";
|
|
43
51
|
|
|
@@ -50,17 +58,27 @@ const COMMAND_STATUS = "debug-status";
|
|
|
50
58
|
|
|
51
59
|
/** Compact transcript lines for the prompts this extension injects. */
|
|
52
60
|
const MESSAGE_SUMMARIES: Record<string, string> = {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
61
|
+
[PROMPT_START]: "debug mode started — hypotheses and instrumentation",
|
|
62
|
+
[PROMPT_PROCEED]: "proceed — analyzing captured logs",
|
|
63
|
+
[PROMPT_FIXED]: "marked fixed — removing probes and summarizing",
|
|
56
64
|
};
|
|
57
65
|
|
|
58
66
|
interface DebugMessageDetails {
|
|
59
67
|
summary?: string;
|
|
60
68
|
}
|
|
61
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
|
+
|
|
62
80
|
export function registerDebugMode(pi: ExtensionAPI): void {
|
|
63
|
-
|
|
81
|
+
let state: DebugState = INACTIVE;
|
|
64
82
|
let uiCtx: ExtensionContext | null = null;
|
|
65
83
|
let watchedLogFile: string | null = null;
|
|
66
84
|
const lineCounter = new JsonlLineCounter();
|
|
@@ -73,79 +91,164 @@ export function registerDebugMode(pi: ExtensionAPI): void {
|
|
|
73
91
|
pi.registerMessageRenderer<DebugMessageDetails>(customType, renderer);
|
|
74
92
|
}
|
|
75
93
|
|
|
76
|
-
// ==============================
|
|
94
|
+
// ============================== state plumbing ==============================
|
|
77
95
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
96
|
+
/**
|
|
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.
|
|
101
|
+
*/
|
|
102
|
+
function absorbCache(event: Extract<DebugEvent, { t: "runs_observed" | "ledger_synced" | "tool_used" }>): void {
|
|
103
|
+
state = reduce(state, event).state;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Run one event through the machine, then realise whatever it asked for. */
|
|
107
|
+
function dispatch(event: DebugEvent, ctx: ExtensionContext): string | null {
|
|
108
|
+
const transition = reduce(state, event);
|
|
109
|
+
// The reducer returns the previous object for a genuine no-op, so a turn
|
|
110
|
+
// full of tool calls does not append a state snapshot per message.
|
|
111
|
+
const changed = transition.state !== state;
|
|
112
|
+
state = transition.state;
|
|
113
|
+
if (changed) pi.appendEntry(DEBUG_ENTRY, { ...state });
|
|
114
|
+
refreshUi();
|
|
115
|
+
watchLogFile();
|
|
116
|
+
let continueContext: string | null = null;
|
|
117
|
+
for (const effect of transition.effects) {
|
|
118
|
+
const context = applyEffect(effect, ctx);
|
|
119
|
+
if (context) continueContext = context;
|
|
89
120
|
}
|
|
121
|
+
return continueContext;
|
|
90
122
|
}
|
|
91
123
|
|
|
124
|
+
function applyEffect(effect: Effect, ctx: ExtensionContext): string | null {
|
|
125
|
+
switch (effect.kind) {
|
|
126
|
+
case "notify":
|
|
127
|
+
ctx.ui.notify(effect.text, effect.level);
|
|
128
|
+
return null;
|
|
129
|
+
case "prompt":
|
|
130
|
+
pi.sendMessage(
|
|
131
|
+
{
|
|
132
|
+
customType: effect.customType,
|
|
133
|
+
content: effect.content,
|
|
134
|
+
display: true,
|
|
135
|
+
details: { summary: effect.summary } satisfies DebugMessageDetails,
|
|
136
|
+
},
|
|
137
|
+
{ triggerTurn: true },
|
|
138
|
+
);
|
|
139
|
+
return null;
|
|
140
|
+
case "continue":
|
|
141
|
+
return effect.context;
|
|
142
|
+
case "teardown": {
|
|
143
|
+
unwatchLogFile();
|
|
144
|
+
lineCounter.clear();
|
|
145
|
+
if (effect.debugDir) {
|
|
146
|
+
try {
|
|
147
|
+
fs.rmSync(effect.debugDir, { recursive: true, force: true });
|
|
148
|
+
pruneDebugRoot(ctx.cwd);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
pi.logger.warn("debug-mode: failed to remove debug dir", { err });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const label = effect.outcome === "finished" ? "finished" : "aborted";
|
|
154
|
+
if (effect.probesLeft.length > 0) {
|
|
155
|
+
ctx.ui.notify(
|
|
156
|
+
`Debug mode ${label}, but ${effect.probesLeft.length} probe(s) remain in code: ${effect.probesLeft.map(p => p.id).join(", ")} — remove manually. Applied fixes remain in the working diff.`,
|
|
157
|
+
"warning",
|
|
158
|
+
);
|
|
159
|
+
} else {
|
|
160
|
+
ctx.ui.notify(
|
|
161
|
+
`Debug mode ${label}. Log files removed; applied fixes remain in the working diff for review.`,
|
|
162
|
+
"info",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
void setDebugToolsActive(false);
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function refreshUi(): void {
|
|
172
|
+
applyUi(uiCtx, state, currentLogCount);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ============================== log files ==============================
|
|
176
|
+
|
|
92
177
|
function readRunLines(run: string): string[] {
|
|
93
|
-
return readJsonlLines(logFileFor(state, run));
|
|
178
|
+
return state.active ? readJsonlLines(logFileFor(state, run)) : [];
|
|
94
179
|
}
|
|
95
180
|
|
|
181
|
+
/** Re-count every run on disk. Pure cache maintenance — never a decision. */
|
|
96
182
|
function refreshLogCounts(): void {
|
|
97
|
-
|
|
98
|
-
|
|
183
|
+
if (!state.active) return;
|
|
184
|
+
const session: DebugSession = state;
|
|
185
|
+
const active = activeRunId(session);
|
|
186
|
+
const runs = new Set(Object.keys(session.logCounts));
|
|
187
|
+
if (session.debugDir) {
|
|
99
188
|
try {
|
|
100
|
-
for (const file of fs.readdirSync(
|
|
189
|
+
for (const file of fs.readdirSync(session.debugDir)) {
|
|
101
190
|
if (!file.endsWith(".jsonl")) continue;
|
|
102
191
|
if (file === ACTIVE_LOG_FILE) {
|
|
103
|
-
if (
|
|
192
|
+
if (active) runs.add(active);
|
|
104
193
|
} else {
|
|
105
194
|
runs.add(file.replace(/\.jsonl$/, ""));
|
|
106
195
|
}
|
|
107
196
|
}
|
|
108
197
|
} catch {}
|
|
109
198
|
}
|
|
110
|
-
|
|
111
|
-
|
|
199
|
+
const logCounts: Record<string, number> = {};
|
|
200
|
+
for (const run of runs) logCounts[run] = lineCounter.count(logFileFor(session, run));
|
|
201
|
+
absorbCache({ t: "runs_observed", runHistory: orderedHistory(session, runs), logCounts });
|
|
112
202
|
}
|
|
113
203
|
|
|
114
204
|
/** Keep the ordered history complete when runs are discovered from disk. */
|
|
115
|
-
function
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const completed =
|
|
205
|
+
function orderedHistory(session: DebugSession, runs: Iterable<string>): string[] {
|
|
206
|
+
const active = activeRunId(session);
|
|
207
|
+
const missing = [...runs].filter(run => run !== active && !session.runHistory.includes(run));
|
|
208
|
+
if (missing.length === 0) return session.runHistory;
|
|
209
|
+
const completed = session.runHistory.filter(run => run !== active).concat(missing);
|
|
120
210
|
completed.sort(compareRunIds);
|
|
121
|
-
|
|
211
|
+
return active ? [...completed, active] : completed;
|
|
122
212
|
}
|
|
123
213
|
|
|
124
214
|
function currentLogCount(): number {
|
|
125
215
|
refreshLogCounts();
|
|
126
|
-
|
|
216
|
+
if (!state.active) return 0;
|
|
217
|
+
const run = activeRunId(state);
|
|
218
|
+
return run ? (state.logCounts[run] ?? 0) : 0;
|
|
219
|
+
}
|
|
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);
|
|
127
227
|
}
|
|
128
228
|
|
|
129
|
-
function
|
|
130
|
-
if (
|
|
131
|
-
|
|
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
|
+
|
|
236
|
+
/** Archive the active log, truncate it for the next reproduction, and name the run. */
|
|
237
|
+
function createRun(
|
|
238
|
+
debugDir: string,
|
|
239
|
+
round: number,
|
|
240
|
+
previousRun: string | null,
|
|
241
|
+
): { runId: string } | { error: string } {
|
|
132
242
|
try {
|
|
133
|
-
prepareRunLog(
|
|
243
|
+
prepareRunLog(debugDir, previousRun);
|
|
134
244
|
} catch (err) {
|
|
135
245
|
pi.logger.error("debug-mode: cannot initialize run log", {
|
|
136
|
-
file: path.join(
|
|
246
|
+
file: path.join(debugDir, ACTIVE_LOG_FILE),
|
|
137
247
|
err,
|
|
138
248
|
});
|
|
139
|
-
return
|
|
249
|
+
return { error: fsErrorMessage(err) };
|
|
140
250
|
}
|
|
141
|
-
|
|
142
|
-
state.runHistory.push(run);
|
|
143
|
-
state.logCounts[run] = 0;
|
|
144
|
-
return run;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function refreshUi(): void {
|
|
148
|
-
applyUi(uiCtx, state, currentLogCount);
|
|
251
|
+
return { runId: `run${round}-${Date.now().toString(36)}` };
|
|
149
252
|
}
|
|
150
253
|
|
|
151
254
|
/**
|
|
@@ -154,13 +257,13 @@ export function registerDebugMode(pi: ExtensionAPI): void {
|
|
|
154
257
|
* refresh the widget as observations land.
|
|
155
258
|
*/
|
|
156
259
|
function watchLogFile(): void {
|
|
157
|
-
const file = state.
|
|
260
|
+
const file = state.active && state.stage === "user_turn" ? logFileFor(state) : null;
|
|
158
261
|
if (file === watchedLogFile) return;
|
|
159
262
|
unwatchLogFile();
|
|
160
263
|
if (!file || !uiCtx?.hasUI) return;
|
|
161
264
|
try {
|
|
162
265
|
fs.watchFile(file, { interval: 1000 }, (curr, prev) => {
|
|
163
|
-
if (state.
|
|
266
|
+
if (!state.active || state.stage !== "user_turn") return;
|
|
164
267
|
if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) return;
|
|
165
268
|
try {
|
|
166
269
|
refreshUi();
|
|
@@ -186,336 +289,292 @@ export function registerDebugMode(pi: ExtensionAPI): void {
|
|
|
186
289
|
|
|
187
290
|
// ============================== probe ledger ==============================
|
|
188
291
|
|
|
292
|
+
/** Rescan the recorded files so the ledger matches the code on disk. */
|
|
293
|
+
async function syncLedger(): Promise<LedgerScan> {
|
|
294
|
+
const scan = await scanLedger(state.active ? state.probes : []);
|
|
295
|
+
if (state.active) absorbCache({ t: "ledger_synced", probes: survivingProbes(scan) });
|
|
296
|
+
return scan;
|
|
297
|
+
}
|
|
298
|
+
|
|
189
299
|
pi.on("tool_call", async (event, ctx) => {
|
|
190
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" });
|
|
191
304
|
if (event.toolName !== "edit" && event.toolName !== "write") return;
|
|
192
|
-
|
|
305
|
+
const probes = probesInInput(event.input as Record<string, unknown>, ctx.cwd, currentRound(state).index);
|
|
306
|
+
if (probes.length > 0) dispatch({ t: "probes_found", probes }, ctx);
|
|
193
307
|
});
|
|
194
308
|
|
|
195
309
|
// ============================== prompt injection ==============================
|
|
196
310
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
// The blackboard claims to be ground truth, so reconcile it with disk first.
|
|
200
|
-
await syncLedger(state);
|
|
311
|
+
/** The blackboard plus the contract for the stage the session is in now. */
|
|
312
|
+
function injectedContext(session: DebugSession): string {
|
|
201
313
|
// Cleanup has no hypotheses left to form; the full methodology would only
|
|
202
314
|
// invite another round.
|
|
203
|
-
const contract =
|
|
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.
|
|
327
|
+
pi.on("before_agent_start", async () => {
|
|
328
|
+
if (!state.active) return;
|
|
329
|
+
// The blackboard claims to be ground truth, so reconcile it with disk first.
|
|
330
|
+
await syncLedger();
|
|
331
|
+
if (!state.active) return;
|
|
204
332
|
return {
|
|
205
333
|
message: {
|
|
206
334
|
customType: DEBUG_CONTEXT_TYPE,
|
|
207
|
-
content:
|
|
208
|
-
|
|
335
|
+
content: injectedContext(state),
|
|
336
|
+
display: false,
|
|
337
|
+
attribution: "agent",
|
|
209
338
|
},
|
|
210
339
|
};
|
|
211
340
|
});
|
|
212
341
|
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
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.
|
|
216
347
|
pi.on("context", async (event) => {
|
|
217
|
-
const
|
|
218
|
-
|
|
348
|
+
const messages = syncCustomType(
|
|
349
|
+
event.messages,
|
|
350
|
+
DEBUG_CONTEXT_TYPE,
|
|
351
|
+
state.active ? injectedContext(state) : null,
|
|
352
|
+
);
|
|
353
|
+
if (messages) return { messages };
|
|
219
354
|
});
|
|
220
355
|
|
|
221
|
-
// ==============================
|
|
356
|
+
// ============================== turn lifecycle ==============================
|
|
222
357
|
|
|
223
|
-
pi.on("agent_start", async () => {
|
|
224
|
-
if (state.active)
|
|
358
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
359
|
+
if (state.active) dispatch({ t: "turn_started" }, ctx);
|
|
225
360
|
});
|
|
226
361
|
|
|
227
|
-
pi.on("message_end", async (event) => {
|
|
362
|
+
pi.on("message_end", async (event, ctx) => {
|
|
228
363
|
if (!state.active) return;
|
|
229
364
|
const msg = event.message as { role?: string; content?: unknown };
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (state.phase === "round") {
|
|
237
|
-
const plan = parseEvidencePlan(text, state.round);
|
|
238
|
-
if (plan.found) {
|
|
239
|
-
replaceRoundEvidenceRequests(state, state.round, plan.valid ? plan.requests : []);
|
|
240
|
-
pi.appendEntry(DEBUG_ENTRY, { ...state });
|
|
241
|
-
}
|
|
242
|
-
}
|
|
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;
|
|
243
371
|
}
|
|
372
|
+
if (msg?.role !== "assistant") return;
|
|
373
|
+
dispatch({ t: "assistant_message", text: extractAssistantText(msg.content) }, ctx);
|
|
244
374
|
});
|
|
245
375
|
|
|
246
376
|
pi.on("session_stop", async (_event, ctx) => {
|
|
247
|
-
if (!state.active) return;
|
|
248
|
-
if (state.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (
|
|
255
|
-
const probesThisRound = state.probes.filter(p => p.round === state.round).length;
|
|
256
|
-
const hasEvidencePlan = state.evidenceRequests.some(request => request.round === state.round);
|
|
257
|
-
const decision = decideGate({
|
|
258
|
-
hasReproductionSteps: state.reproductionSteps.length > 0,
|
|
259
|
-
hasEvidencePlan,
|
|
260
|
-
probesThisRound,
|
|
261
|
-
nudgesUsed: state.gateNudges,
|
|
262
|
-
});
|
|
263
|
-
if (decision.kind === "stay") return;
|
|
264
|
-
if (decision.kind === "nudge") {
|
|
265
|
-
state.gateNudges += 1;
|
|
266
|
-
return { continue: true, additionalContext: decision.context };
|
|
267
|
-
}
|
|
268
|
-
enterGate(ctx, probesThisRound, decision.missingSteps, decision.missingEvidencePlan);
|
|
377
|
+
if (!state.active || !state.turnProduced) return;
|
|
378
|
+
if (state.stage !== "investigating" && state.stage !== "cleaning_up") return;
|
|
379
|
+
// A declared runtime_probe must be backed by a marker that is really on
|
|
380
|
+
// disk, so the ledger is reconciled before the round may close.
|
|
381
|
+
await syncLedger();
|
|
382
|
+
const context = dispatch({ t: "turn_settled" }, ctx);
|
|
383
|
+
await setDebugToolsActive(state.active);
|
|
384
|
+
if (context) return { continue: true, additionalContext: context };
|
|
269
385
|
});
|
|
270
386
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
):
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
} else if (pending.length > 0) {
|
|
296
|
-
ctx.ui.notify(
|
|
297
|
-
`Debug round ${state.round} paused. User evidence requested (${pending.length} pending: ${pending.map(r => r.id).join(", ")}) — attach via /${COMMAND_EVIDENCE} <request-id> <path>, then ${PROCEED_REMINDER}`,
|
|
298
|
-
"info",
|
|
299
|
-
);
|
|
300
|
-
} else if (missingEvidencePlan) {
|
|
301
|
-
ctx.ui.notify(
|
|
302
|
-
`Debug round ${state.round} paused. The gate is usable, but the next Proceed will ask the model to declare an evidence method.`,
|
|
303
|
-
"info",
|
|
304
|
-
);
|
|
305
|
-
} else {
|
|
306
|
-
ctx.ui.notify(`Debug round ${state.round} paused. Reproduce the bug, then ${PROCEED_REMINDER}`, "info");
|
|
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
|
+
};
|
|
307
411
|
}
|
|
308
|
-
}
|
|
309
412
|
|
|
310
|
-
|
|
413
|
+
registerDebugTools(pi, {
|
|
414
|
+
getState: () => state,
|
|
415
|
+
refreshLogCounts,
|
|
416
|
+
readRunLines,
|
|
417
|
+
syncLedger,
|
|
418
|
+
handOff: applyHandoffRequest,
|
|
419
|
+
});
|
|
311
420
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
state.evidenceArtifacts = [];
|
|
330
|
-
state.evidenceObservations = [];
|
|
331
|
-
if (!newRun()) {
|
|
332
|
-
Object.assign(state, freshState());
|
|
333
|
-
ctx.ui.notify("debug-mode: could not initialize the run log file; debug mode was not started", "error");
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
refreshUi();
|
|
337
|
-
const logFile = logFileFor(state);
|
|
338
|
-
if (!logFile) {
|
|
339
|
-
Object.assign(state, freshState());
|
|
340
|
-
ctx.ui.notify("debug-mode: could not resolve the run log file; debug mode was not started", "error");
|
|
341
|
-
return;
|
|
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 });
|
|
342
438
|
}
|
|
343
|
-
pi.sendMessage(
|
|
344
|
-
{
|
|
345
|
-
customType: "debug-mode-start",
|
|
346
|
-
content: buildStartMessage(problem, logFile),
|
|
347
|
-
display: true,
|
|
348
|
-
details: { summary: "debug mode started — hypotheses and instrumentation" } satisfies DebugMessageDetails,
|
|
349
|
-
},
|
|
350
|
-
{ triggerTurn: true },
|
|
351
|
-
);
|
|
352
439
|
}
|
|
353
440
|
|
|
354
|
-
|
|
441
|
+
// ============================== command guards ==============================
|
|
442
|
+
|
|
443
|
+
function activeSession(ctx: ExtensionContext): DebugSession | null {
|
|
355
444
|
if (!state.active) {
|
|
356
445
|
ctx.ui.notify("debug-mode: not active", "error");
|
|
357
|
-
return
|
|
446
|
+
return null;
|
|
358
447
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
448
|
+
return state;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
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.
|
|
459
|
+
*/
|
|
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") {
|
|
465
|
+
ctx.ui.notify(
|
|
466
|
+
`debug-mode: cleanup is still finishing — reply to the agent, or use /${COMMAND_ABORT}.`,
|
|
467
|
+
"error",
|
|
468
|
+
);
|
|
469
|
+
return null;
|
|
362
470
|
}
|
|
363
|
-
|
|
471
|
+
if (state.stage === "investigating") dispatch({ t: "reclaim" }, ctx);
|
|
472
|
+
return state.active && state.stage === "user_turn" ? state : null;
|
|
364
473
|
}
|
|
365
474
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
);
|
|
372
|
-
if (
|
|
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) {
|
|
373
485
|
const confirmed = await ctx.ui.confirm(
|
|
374
|
-
|
|
375
|
-
|
|
486
|
+
`${action} round ${round.index}?`,
|
|
487
|
+
`${describeHandoff(mode, round.index)} Continue with the evidence that already exists?`,
|
|
376
488
|
);
|
|
377
|
-
if (!confirmed) return;
|
|
489
|
+
if (!confirmed) return null;
|
|
378
490
|
}
|
|
379
|
-
|
|
491
|
+
return state.active ? state : null;
|
|
492
|
+
}
|
|
380
493
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
494
|
+
// ============================== round transitions ==============================
|
|
495
|
+
|
|
496
|
+
async function startDebug(ctx: ExtensionContext, problem: string): Promise<void> {
|
|
497
|
+
const debugDir = debugDirFor(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
498
|
+
try {
|
|
499
|
+
fs.mkdirSync(debugDir, { recursive: true });
|
|
500
|
+
excludeDebugLogsFromGit(ctx.cwd);
|
|
501
|
+
} catch (err) {
|
|
502
|
+
pi.logger.error("debug-mode: cannot create log dir", { dir: debugDir, err });
|
|
503
|
+
ctx.ui.notify("debug-mode: could not create the debug log directory; debug mode was not started", "error");
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
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
|
+
);
|
|
512
|
+
return;
|
|
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);
|
|
518
|
+
dispatch({ t: "start", problem, debugDir, runId, logFile: path.join(debugDir, ACTIVE_LOG_FILE) }, ctx);
|
|
519
|
+
}
|
|
399
520
|
|
|
400
|
-
async function advanceDebug(ctx:
|
|
401
|
-
if (!
|
|
521
|
+
async function advanceDebug(ctx: ExtensionCommandContext, userDetails?: string): Promise<void> {
|
|
522
|
+
if (!(await takeTurn(ctx, "/debug-proceed from"))) return;
|
|
402
523
|
refreshLogCounts();
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
);
|
|
408
|
-
if (logCount === 0 && !userDetails && !hasNonProbeEvidence && ctx.hasUI) {
|
|
524
|
+
if (!state.active) return;
|
|
525
|
+
const closingRun = activeRunId(state);
|
|
526
|
+
const logCount = closingRun ? (state.logCounts[closingRun] ?? 0) : 0;
|
|
527
|
+
if (logCount === 0 && !userDetails && !hasNonProbeEvidence(currentRound(state)) && ctx.hasUI) {
|
|
409
528
|
const confirmed = await ctx.ui.confirm(
|
|
410
|
-
"
|
|
529
|
+
"/debug-proceed without runtime logs?",
|
|
411
530
|
"No runtime observations were captured for this round. Continue to log analysis anyway?",
|
|
412
531
|
);
|
|
413
532
|
if (!confirmed) return;
|
|
414
533
|
}
|
|
415
|
-
if (!state.active
|
|
416
|
-
|
|
417
|
-
// Persist optional /debug-proceed details as a batched user observation
|
|
418
|
-
// tied to every current-round user_report request.
|
|
419
|
-
if (userDetails && userDetails.trim().length > 0) {
|
|
420
|
-
const reportIds = state.evidenceRequests
|
|
421
|
-
.filter(request => request.round === state.round && request.method === "user_report")
|
|
422
|
-
.map(request => request.id);
|
|
423
|
-
const observation: EvidenceObservation = {
|
|
424
|
-
id: `observation-${Date.now().toString(36)}`,
|
|
425
|
-
requestIds: reportIds,
|
|
426
|
-
text: userDetails.trim(),
|
|
427
|
-
round: state.round,
|
|
428
|
-
addedAt: Date.now(),
|
|
429
|
-
};
|
|
430
|
-
state.evidenceObservations = [...state.evidenceObservations, observation];
|
|
431
|
-
}
|
|
432
|
-
const stillPending = pendingEvidenceRequests(state, state.round);
|
|
534
|
+
if (!state.active) return;
|
|
535
|
+
const stillPending = pendingRequests(state);
|
|
433
536
|
if (stillPending.length > 0 && ctx.hasUI) {
|
|
434
537
|
const confirmed = await ctx.ui.confirm(
|
|
435
|
-
"
|
|
538
|
+
"/debug-proceed without all requested evidence?",
|
|
436
539
|
`${stillPending.length} evidence request(s) still pending (${stillPending.map(r => r.id).join(", ")}). Continue anyway?`,
|
|
437
540
|
);
|
|
438
|
-
if (!confirmed)
|
|
439
|
-
pi.appendEntry(DEBUG_ENTRY, { ...state });
|
|
440
|
-
refreshUi();
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
541
|
+
if (!confirmed) return;
|
|
443
542
|
}
|
|
543
|
+
if (!state.active || state.stage !== "user_turn" || !state.debugDir) return;
|
|
444
544
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
state.
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
state.reproductionSteps = previousSteps;
|
|
457
|
-
ctx.ui.notify("debug-mode: could not initialize the next run log file; staying at the reproduction gate", "error");
|
|
458
|
-
refreshUi();
|
|
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.
|
|
547
|
+
const hypotheses = describeHypotheses(summarizeHypotheses(closingRun ? readRunLines(closingRun) : []));
|
|
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
|
+
);
|
|
459
556
|
return;
|
|
460
557
|
}
|
|
461
|
-
|
|
462
|
-
refreshUi();
|
|
463
|
-
|
|
464
|
-
const summary = userDetails
|
|
465
|
-
? `proceed with user details — analyzing run ${run} (${logCount} entries)`
|
|
466
|
-
: `proceed — analyzing run ${run} (${logCount} entries)`;
|
|
467
|
-
pi.sendMessage(
|
|
468
|
-
{
|
|
469
|
-
customType: "debug-mode-proceed",
|
|
470
|
-
content: buildProceedMessage({
|
|
471
|
-
run,
|
|
472
|
-
logCount,
|
|
473
|
-
userDetails,
|
|
474
|
-
hypotheses,
|
|
475
|
-
evidenceSummary: evidenceSummary(state, previousRound),
|
|
476
|
-
}),
|
|
477
|
-
display: true,
|
|
478
|
-
details: { summary } satisfies DebugMessageDetails,
|
|
479
|
-
},
|
|
480
|
-
{ triggerTurn: true },
|
|
481
|
-
);
|
|
558
|
+
dispatch({ t: "proceed", runId: created.runId, logCount, hypotheses, details: userDetails, now: Date.now() }, ctx);
|
|
482
559
|
}
|
|
483
560
|
|
|
484
|
-
async function
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
if (state.
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
pi.logger.warn("debug-mode: failed to remove debug dir", { err });
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
const scan = await syncLedger(state);
|
|
496
|
-
const probesLeft = [...scan.alive, ...scan.unknown];
|
|
497
|
-
Object.assign(state, freshState());
|
|
498
|
-
pi.appendEntry(DEBUG_ENTRY, { ...state });
|
|
499
|
-
refreshUi();
|
|
500
|
-
const resultLabel = outcome === "finished" ? "finished" : "aborted";
|
|
501
|
-
if (probesLeft.length > 0) {
|
|
502
|
-
ctx.ui.notify(
|
|
503
|
-
`Debug mode ${resultLabel}, but ${probesLeft.length} probe(s) remain in code: ${probesLeft.map(p => p.id).join(", ")} — remove manually. Applied fixes remain in the working diff.`,
|
|
504
|
-
"warning",
|
|
505
|
-
);
|
|
506
|
-
} else {
|
|
507
|
-
ctx.ui.notify(
|
|
508
|
-
`Debug mode ${resultLabel}. Log files removed; applied fixes remain in the working diff for review.`,
|
|
509
|
-
"info",
|
|
561
|
+
async function markDebugFixed(ctx: ExtensionCommandContext): Promise<void> {
|
|
562
|
+
if (!(await takeTurn(ctx, "/debug-done from"))) return;
|
|
563
|
+
const logCount = currentLogCount();
|
|
564
|
+
if (!state.active) return;
|
|
565
|
+
if (logCount === 0 && !hasNonProbeEvidence(currentRound(state)) && ctx.hasUI) {
|
|
566
|
+
const confirmed = await ctx.ui.confirm(
|
|
567
|
+
"/debug-done without runtime logs?",
|
|
568
|
+
"No runtime observations were captured for this round. Mark the problem as fixed anyway?",
|
|
510
569
|
);
|
|
570
|
+
if (!confirmed) return;
|
|
511
571
|
}
|
|
572
|
+
if (!state.active || state.stage !== "user_turn") return;
|
|
573
|
+
dispatch({ t: "mark_fixed" }, ctx);
|
|
512
574
|
}
|
|
513
575
|
|
|
514
576
|
async function abortDebug(ctx: ExtensionContext): Promise<void> {
|
|
515
|
-
if (!
|
|
516
|
-
ctx.ui.notify("debug-mode: not active", "error");
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
577
|
+
if (!activeSession(ctx)) return;
|
|
519
578
|
if (ctx.hasUI) {
|
|
520
579
|
const confirmed = await ctx.ui.confirm(
|
|
521
580
|
"Abort debug mode?",
|
|
@@ -524,23 +583,38 @@ function enterGate(
|
|
|
524
583
|
if (!confirmed) return;
|
|
525
584
|
}
|
|
526
585
|
if (!state.active) return;
|
|
527
|
-
|
|
586
|
+
dispatch({ t: "abort" }, ctx);
|
|
587
|
+
await setDebugToolsActive(false);
|
|
528
588
|
}
|
|
529
589
|
|
|
530
590
|
/**
|
|
531
|
-
* Attach one user-provided evidence file to the current
|
|
532
|
-
*
|
|
533
|
-
*
|
|
591
|
+
* Attach one user-provided evidence file to the current round. The file is
|
|
592
|
+
* referenced in place — never copied, moved or deleted — and the action does
|
|
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.
|
|
534
598
|
*/
|
|
535
|
-
async function attachEvidence(ctx:
|
|
536
|
-
|
|
537
|
-
|
|
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);
|
|
603
|
+
if (!session) return false;
|
|
604
|
+
const parsed = parseEvidenceArgument(args, session);
|
|
605
|
+
if (!parsed.ok) {
|
|
606
|
+
ctx.ui.notify(`debug-mode: ${parsed.error}`, "error");
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
const requestId = parsed.requestId;
|
|
610
|
+
let input = parsed.rawPath?.trim() ?? "";
|
|
538
611
|
if (!input) {
|
|
539
612
|
if (!ctx.hasUI) {
|
|
540
|
-
ctx.ui.notify(`Usage: /${COMMAND_EVIDENCE} <path>`, "error");
|
|
613
|
+
ctx.ui.notify(`Usage: /${COMMAND_EVIDENCE} <request-id|${UNLINKED_SELECTOR}> <path>`, "error");
|
|
541
614
|
return false;
|
|
542
615
|
}
|
|
543
|
-
|
|
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")) ?? "";
|
|
544
618
|
if (!input.trim()) {
|
|
545
619
|
ctx.ui.notify("debug-mode: no evidence file path provided", "error");
|
|
546
620
|
return false;
|
|
@@ -564,20 +638,15 @@ function enterGate(
|
|
|
564
638
|
);
|
|
565
639
|
if (!confirmed) return false;
|
|
566
640
|
}
|
|
567
|
-
const
|
|
568
|
-
if (
|
|
569
|
-
ctx.ui.notify(`debug-mode: evidence rejected — ${
|
|
641
|
+
const validation = validateEvidenceArtifact(trimmed, ctx.cwd);
|
|
642
|
+
if (!validation.ok) {
|
|
643
|
+
ctx.ui.notify(`debug-mode: evidence rejected — ${validation.reason}`, "error");
|
|
570
644
|
return false;
|
|
571
645
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
refreshUi();
|
|
575
|
-
ctx.ui.notify(
|
|
576
|
-
`debug-mode: attached ${result.artifact.id} → ${result.artifact.path} (${result.artifact.size} bytes)`,
|
|
577
|
-
"info",
|
|
578
|
-
);
|
|
646
|
+
if (!state.active) return false;
|
|
647
|
+
dispatch({ t: "attach_artifact", candidate: validation.artifact, requestId, now: Date.now() }, ctx);
|
|
579
648
|
return true;
|
|
580
|
-
}
|
|
649
|
+
}
|
|
581
650
|
|
|
582
651
|
// ============================== commands ==============================
|
|
583
652
|
|
|
@@ -597,7 +666,7 @@ function enterGate(
|
|
|
597
666
|
);
|
|
598
667
|
return;
|
|
599
668
|
}
|
|
600
|
-
startDebug(ctx, problem);
|
|
669
|
+
await startDebug(ctx, problem);
|
|
601
670
|
},
|
|
602
671
|
});
|
|
603
672
|
|
|
@@ -618,36 +687,15 @@ function enterGate(
|
|
|
618
687
|
},
|
|
619
688
|
});
|
|
620
689
|
|
|
621
|
-
/**
|
|
622
|
-
* Parse `/debug-evidence [<request-id>] <path>`: when the first
|
|
623
|
-
* whitespace-delimited token names a pending current-round user_artifact
|
|
624
|
-
* request, link to it and treat the remainder (spaces intact) as the path;
|
|
625
|
-
* otherwise the whole argument is an unlinked path.
|
|
626
|
-
*/
|
|
627
|
-
function parseEvidenceArgument(args: string): { requestId: string | null; rawPath: string | undefined } {
|
|
628
|
-
const trimmed = args.trim();
|
|
629
|
-
if (!trimmed) return { requestId: null, rawPath: undefined };
|
|
630
|
-
const firstToken = trimmed.split(/\s+/, 1)[0];
|
|
631
|
-
const isPending = pendingEvidenceRequests(state, state.round).some(
|
|
632
|
-
request => request.method === "user_artifact" && request.id === firstToken,
|
|
633
|
-
);
|
|
634
|
-
if (isPending) {
|
|
635
|
-
const rest = trimmed.slice(firstToken.length).trim();
|
|
636
|
-
return rest ? { requestId: firstToken, rawPath: rest } : { requestId: firstToken, rawPath: undefined };
|
|
637
|
-
}
|
|
638
|
-
return { requestId: null, rawPath: trimmed };
|
|
639
|
-
}
|
|
640
|
-
|
|
641
690
|
pi.registerCommand(COMMAND_EVIDENCE, {
|
|
642
|
-
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)),
|
|
643
693
|
handler: async (args, ctx) => {
|
|
644
694
|
uiCtx = ctx;
|
|
645
|
-
|
|
646
|
-
await attachEvidence(ctx, rawPath, requestId);
|
|
695
|
+
await attachEvidence(ctx, args);
|
|
647
696
|
},
|
|
648
697
|
});
|
|
649
698
|
|
|
650
|
-
|
|
651
699
|
pi.registerCommand(COMMAND_ABORT, {
|
|
652
700
|
description: "Abort debug mode: delete logs (fixes stay in the working diff)",
|
|
653
701
|
handler: async (_args, ctx) => {
|
|
@@ -665,11 +713,15 @@ function enterGate(
|
|
|
665
713
|
return;
|
|
666
714
|
}
|
|
667
715
|
refreshLogCounts();
|
|
668
|
-
const scan = await syncLedger(
|
|
669
|
-
|
|
670
|
-
const
|
|
671
|
-
const
|
|
672
|
-
const
|
|
716
|
+
const scan = await syncLedger();
|
|
717
|
+
if (!state.active) return;
|
|
718
|
+
const session: DebugSession = state;
|
|
719
|
+
const round = currentRound(session);
|
|
720
|
+
const run = activeRunId(session);
|
|
721
|
+
const tallies = run ? summarizeHypotheses(readRunLines(run)) : [];
|
|
722
|
+
const requests = round.plan ?? [];
|
|
723
|
+
const pending = pendingRequests(session, round);
|
|
724
|
+
const unavailable = session.artifacts.filter(artifact => {
|
|
673
725
|
try {
|
|
674
726
|
const stats = fs.statSync(artifact.path);
|
|
675
727
|
fs.accessSync(artifact.path, fs.constants.R_OK);
|
|
@@ -679,25 +731,22 @@ function enterGate(
|
|
|
679
731
|
}
|
|
680
732
|
});
|
|
681
733
|
ctx.ui.notify(
|
|
682
|
-
`debug-mode:
|
|
734
|
+
`debug-mode: stage=${session.stage} round=${round.index} run=${run ?? "(none)"}\n` +
|
|
735
|
+
(round.handoff ? `${describeHandoff(round.handoff, round.index, round.plan === null)}\n` : "") +
|
|
683
736
|
`${describeLedger(scan)}\n` +
|
|
684
|
-
`logs: ${
|
|
737
|
+
`logs: ${session.runHistory.map(r => `${r}=${session.logCounts[r] ?? 0}`).join(", ") || "(none)"}\n` +
|
|
685
738
|
`this run by hypothesis: ${describeHypotheses(tallies)}\n` +
|
|
686
|
-
`current log file: ${logFileFor(
|
|
687
|
-
`evidence requests: ${
|
|
739
|
+
`current log file: ${logFileFor(session) ?? "(not initialized)"}\n` +
|
|
740
|
+
`evidence requests: ${requests.map(r => `${r.id}[${r.method}] ${r.title}`).join("; ") || "(none)"}\n` +
|
|
688
741
|
`pending: ${pending.map(r => r.id).join(", ") || "(none)"}\n` +
|
|
689
|
-
`observations: ${
|
|
690
|
-
`artifacts: ${
|
|
742
|
+
`observations: ${session.observations.map(o => o.id).join(", ") || "(none)"}\n` +
|
|
743
|
+
`artifacts: ${session.artifacts.map(a => a.id).join(", ") || "(none)"}` +
|
|
691
744
|
(unavailable.length > 0 ? `\nunavailable artifacts: ${unavailable.map(a => `${a.id} ${a.path}`).join(", ")}` : ""),
|
|
692
745
|
"info",
|
|
693
746
|
);
|
|
694
747
|
},
|
|
695
748
|
});
|
|
696
749
|
|
|
697
|
-
// ============================== tools ==============================
|
|
698
|
-
|
|
699
|
-
registerDebugTools(pi, { state, refreshLogCounts, readRunLines });
|
|
700
|
-
|
|
701
750
|
// ============================== lifecycle ==============================
|
|
702
751
|
|
|
703
752
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -705,21 +754,24 @@ function enterGate(
|
|
|
705
754
|
const entries = ctx.sessionManager.getEntries();
|
|
706
755
|
const last = entries
|
|
707
756
|
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === DEBUG_ENTRY)
|
|
708
|
-
.pop() as { data?:
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
if (!
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
757
|
+
.pop() as { data?: unknown } | undefined;
|
|
758
|
+
state = reviveState(last?.data);
|
|
759
|
+
if (state.active) {
|
|
760
|
+
if (!state.debugDir || !fs.existsSync(state.debugDir)) {
|
|
761
|
+
const debugDir = debugDirFor(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
762
|
+
try {
|
|
763
|
+
fs.mkdirSync(debugDir, { recursive: true });
|
|
764
|
+
excludeDebugLogsFromGit(ctx.cwd);
|
|
765
|
+
state = { ...state, debugDir };
|
|
766
|
+
} catch (err) {
|
|
767
|
+
pi.logger.error("debug-mode: cannot restore log dir", { dir: debugDir, err });
|
|
768
|
+
}
|
|
769
|
+
}
|
|
719
770
|
const currentFile = logFileFor(state);
|
|
720
771
|
if (currentFile) {
|
|
721
772
|
try {
|
|
722
|
-
const
|
|
773
|
+
const run = activeRunId(state);
|
|
774
|
+
const legacyRunFile = state.debugDir && run ? path.join(state.debugDir, `${run}.jsonl`) : null;
|
|
723
775
|
if (!fs.existsSync(currentFile) && legacyRunFile && fs.existsSync(legacyRunFile)) {
|
|
724
776
|
fs.renameSync(legacyRunFile, currentFile);
|
|
725
777
|
}
|
|
@@ -729,19 +781,21 @@ function enterGate(
|
|
|
729
781
|
}
|
|
730
782
|
}
|
|
731
783
|
refreshLogCounts();
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
784
|
+
if (state.active) {
|
|
785
|
+
const round = currentRound(state);
|
|
786
|
+
ctx.ui.notify(
|
|
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}.`,
|
|
790
|
+
"info",
|
|
791
|
+
);
|
|
792
|
+
}
|
|
736
793
|
}
|
|
794
|
+
await setDebugToolsActive(state.active);
|
|
737
795
|
refreshUi();
|
|
738
796
|
watchLogFile();
|
|
739
797
|
});
|
|
740
798
|
|
|
741
|
-
pi.on("turn_start", async () => {
|
|
742
|
-
if (state.active) pi.appendEntry(DEBUG_ENTRY, { ...state });
|
|
743
|
-
});
|
|
744
|
-
|
|
745
799
|
pi.on("session_shutdown", async () => {
|
|
746
800
|
unwatchLogFile();
|
|
747
801
|
});
|