@ian-pascoe/pi-dap 0.1.0
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/LICENSE +21 -0
- package/README.md +164 -0
- package/package.json +57 -0
- package/src/dap-observer-ui.ts +337 -0
- package/src/dap-protocol-client.ts +1103 -0
- package/src/dap-session-files.ts +110 -0
- package/src/dap-session.ts +1231 -0
- package/src/dap-tool-contract.ts +263 -0
- package/src/dap-tool-rendering.ts +512 -0
- package/src/dap-tool.ts +420 -0
- package/src/index.ts +1 -0
- package/src/pi-dap-extension.ts +110 -0
- package/src/pi-dap-settings.ts +406 -0
package/src/dap-tool.ts
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_MAX_BYTES,
|
|
4
|
+
DEFAULT_MAX_LINES,
|
|
5
|
+
truncateHead,
|
|
6
|
+
type AgentToolResult,
|
|
7
|
+
type ExtensionAPI,
|
|
8
|
+
type ToolDefinition,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Value } from "typebox/value";
|
|
11
|
+
import type {
|
|
12
|
+
DapEvaluateInput,
|
|
13
|
+
DapLaunchInput,
|
|
14
|
+
DapSession,
|
|
15
|
+
DapSessionResult,
|
|
16
|
+
DapStackInput,
|
|
17
|
+
DapVariablesInput,
|
|
18
|
+
} from "./dap-session.js";
|
|
19
|
+
import type { DapSessionFiles } from "./dap-session-files.js";
|
|
20
|
+
import {
|
|
21
|
+
DapToolParametersSchema,
|
|
22
|
+
DapToolResultDetailsSchema,
|
|
23
|
+
type DapPresentationDetails,
|
|
24
|
+
type DapToolParameters,
|
|
25
|
+
type DapToolRenderDetails,
|
|
26
|
+
type DapToolResultDetails,
|
|
27
|
+
} from "./dap-tool-contract.js";
|
|
28
|
+
import { renderDapToolCall, renderDapToolResult } from "./dap-tool-rendering.js";
|
|
29
|
+
|
|
30
|
+
type DapToolSession = Pick<
|
|
31
|
+
DapSession,
|
|
32
|
+
| "launch"
|
|
33
|
+
| "setBreakpoints"
|
|
34
|
+
| "continue"
|
|
35
|
+
| "next"
|
|
36
|
+
| "stepIn"
|
|
37
|
+
| "stepOut"
|
|
38
|
+
| "pause"
|
|
39
|
+
| "stack"
|
|
40
|
+
| "variables"
|
|
41
|
+
| "evaluate"
|
|
42
|
+
| "status"
|
|
43
|
+
| "stop"
|
|
44
|
+
>;
|
|
45
|
+
|
|
46
|
+
/** Session-scoped resources resolved at execution time so Pi reloads replace settings safely. */
|
|
47
|
+
export interface DapToolRuntime {
|
|
48
|
+
/** Active Debug Session owner for this Pi conversation session. */
|
|
49
|
+
readonly session: DapToolSession;
|
|
50
|
+
/** Private Result Spill storage owned by the same Pi conversation session. */
|
|
51
|
+
readonly sessionFiles: DapSessionFiles;
|
|
52
|
+
/** Non-authoritative Observer UI hooks for tool presentation context. */
|
|
53
|
+
readonly observer?: DapToolObserver;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Narrow Observer UI dependency that cannot dispatch Debug Adapter requests. */
|
|
57
|
+
export interface DapToolObserver {
|
|
58
|
+
/** Record explicit tool arguments before execution begins. */
|
|
59
|
+
onToolStart(parameters: DapToolParameters): void;
|
|
60
|
+
/** Record one successful operation and its already-returned Debug Session result. */
|
|
61
|
+
onToolSuccess(parameters: DapToolParameters, result: DapSessionResult): void;
|
|
62
|
+
/** Record one failed operation without changing its error. */
|
|
63
|
+
onToolFailure(parameters: DapToolParameters, error: Error): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function piDapError(cause: unknown): Error {
|
|
67
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
68
|
+
return new Error(message.startsWith("Pi DAP:") ? message : `Pi DAP: ${message}`, { cause });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseDapToolParameters(input: DapToolParameters): DapToolParameters {
|
|
72
|
+
try {
|
|
73
|
+
return Value.Parse(DapToolParametersSchema, input);
|
|
74
|
+
} catch (cause) {
|
|
75
|
+
throw piDapError(
|
|
76
|
+
`invalid tool arguments: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isDapExecutionWaitOperation(
|
|
82
|
+
operation: DapToolParameters["operation"],
|
|
83
|
+
): operation is "launch" | "continue" | "next" | "step_in" | "step_out" {
|
|
84
|
+
return (
|
|
85
|
+
operation === "launch" ||
|
|
86
|
+
operation === "continue" ||
|
|
87
|
+
operation === "next" ||
|
|
88
|
+
operation === "step_in" ||
|
|
89
|
+
operation === "step_out"
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function boundedDapPresentationText(value: string): string {
|
|
94
|
+
if (value.length <= 500) return value;
|
|
95
|
+
const end = value.charCodeAt(498) >= 0xd800 && value.charCodeAt(498) <= 0xdbff ? 498 : 499;
|
|
96
|
+
return `${value.slice(0, end)}…`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function dapVariablePresentation(result: DapSessionResult): DapPresentationDetails | undefined {
|
|
100
|
+
const rows: Extract<DapPresentationDetails, { kind: "variables" }>["rows"][number][] = [];
|
|
101
|
+
let totalRows = 0;
|
|
102
|
+
const appendVariable = (
|
|
103
|
+
variable: NonNullable<DapSessionResult["variables"]>[number],
|
|
104
|
+
group?: string,
|
|
105
|
+
) => {
|
|
106
|
+
totalRows++;
|
|
107
|
+
if (rows.length >= 20) return;
|
|
108
|
+
const row: Extract<
|
|
109
|
+
Extract<DapPresentationDetails, { kind: "variables" }>["rows"][number],
|
|
110
|
+
{ kind: "variable" }
|
|
111
|
+
> = {
|
|
112
|
+
kind: "variable",
|
|
113
|
+
name: boundedDapPresentationText(variable.name),
|
|
114
|
+
value: boundedDapPresentationText(variable.value),
|
|
115
|
+
variables_reference: variable.variablesReference,
|
|
116
|
+
};
|
|
117
|
+
if (group !== undefined) row.group = boundedDapPresentationText(group);
|
|
118
|
+
if (variable.type !== undefined) row.type = boundedDapPresentationText(variable.type);
|
|
119
|
+
rows.push(row);
|
|
120
|
+
};
|
|
121
|
+
if (result.variableGroups !== undefined) {
|
|
122
|
+
for (const group of result.variableGroups) {
|
|
123
|
+
totalRows++;
|
|
124
|
+
if (rows.length < 20) {
|
|
125
|
+
rows.push({
|
|
126
|
+
kind: "group",
|
|
127
|
+
name: boundedDapPresentationText(group.scope.name),
|
|
128
|
+
variables_reference: group.scope.variablesReference,
|
|
129
|
+
expensive: group.scope.expensive,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
for (const variable of group.variables) appendVariable(variable, group.scope.name);
|
|
133
|
+
}
|
|
134
|
+
} else if (result.variables !== undefined) {
|
|
135
|
+
for (const variable of result.variables) appendVariable(variable);
|
|
136
|
+
} else {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
return { kind: "variables", rows, omitted_count: Math.max(0, totalRows - rows.length) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function dapPresentationDetails(result: DapSessionResult): DapPresentationDetails | undefined {
|
|
143
|
+
if (result.breakpoints !== undefined) {
|
|
144
|
+
return {
|
|
145
|
+
kind: "breakpoints",
|
|
146
|
+
rows: result.breakpoints.slice(0, 20).map((breakpoint) => {
|
|
147
|
+
const row: Extract<DapPresentationDetails, { kind: "breakpoints" }>["rows"][number] = {
|
|
148
|
+
verified: breakpoint.verified,
|
|
149
|
+
};
|
|
150
|
+
if (breakpoint.id !== undefined) row.id = breakpoint.id;
|
|
151
|
+
if (breakpoint.message !== undefined) {
|
|
152
|
+
row.message = boundedDapPresentationText(breakpoint.message);
|
|
153
|
+
}
|
|
154
|
+
if (breakpoint.line !== undefined) row.line = breakpoint.line;
|
|
155
|
+
if (breakpoint.source?.name !== undefined) {
|
|
156
|
+
row.source_name = boundedDapPresentationText(breakpoint.source.name);
|
|
157
|
+
}
|
|
158
|
+
if (breakpoint.source?.path !== undefined) {
|
|
159
|
+
row.source_path = boundedDapPresentationText(breakpoint.source.path);
|
|
160
|
+
}
|
|
161
|
+
return row;
|
|
162
|
+
}),
|
|
163
|
+
omitted_count: Math.max(0, result.breakpoints.length - 20),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (result.stackFrames !== undefined) {
|
|
167
|
+
const totalCount = result.totalFrames ?? result.stackFrames.length;
|
|
168
|
+
return {
|
|
169
|
+
kind: "stack_frames",
|
|
170
|
+
rows: result.stackFrames.slice(0, 20).map((frame) => {
|
|
171
|
+
const row: Extract<DapPresentationDetails, { kind: "stack_frames" }>["rows"][number] = {
|
|
172
|
+
id: frame.id,
|
|
173
|
+
name: boundedDapPresentationText(frame.name),
|
|
174
|
+
line: frame.line,
|
|
175
|
+
column: frame.column,
|
|
176
|
+
};
|
|
177
|
+
if (frame.source?.name !== undefined) {
|
|
178
|
+
row.source_name = boundedDapPresentationText(frame.source.name);
|
|
179
|
+
}
|
|
180
|
+
if (frame.source?.path !== undefined) {
|
|
181
|
+
row.source_path = boundedDapPresentationText(frame.source.path);
|
|
182
|
+
}
|
|
183
|
+
return row;
|
|
184
|
+
}),
|
|
185
|
+
total_count: totalCount,
|
|
186
|
+
omitted_count: Math.max(0, totalCount - Math.min(20, result.stackFrames.length)),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const variables = dapVariablePresentation(result);
|
|
190
|
+
if (variables !== undefined) return variables;
|
|
191
|
+
if (result.evaluation === undefined) return undefined;
|
|
192
|
+
const evaluation: Extract<DapPresentationDetails, { kind: "evaluation" }> = {
|
|
193
|
+
kind: "evaluation",
|
|
194
|
+
value: boundedDapPresentationText(result.evaluation.result),
|
|
195
|
+
variables_reference: result.evaluation.variablesReference,
|
|
196
|
+
};
|
|
197
|
+
if (result.evaluation.type !== undefined) {
|
|
198
|
+
evaluation.type = boundedDapPresentationText(result.evaluation.type);
|
|
199
|
+
}
|
|
200
|
+
return evaluation;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function toolResultDetails(
|
|
204
|
+
operation: DapToolParameters["operation"],
|
|
205
|
+
result: DapSessionResult,
|
|
206
|
+
executionWaitCancelled: boolean,
|
|
207
|
+
): DapToolResultDetails {
|
|
208
|
+
const snapshot = result.snapshot;
|
|
209
|
+
const details: DapToolResultDetails = {
|
|
210
|
+
operation,
|
|
211
|
+
state: snapshot.state,
|
|
212
|
+
output_discarded_bytes: result.discardedOutputBytes,
|
|
213
|
+
output_truncated: result.discardedOutputBytes > 0,
|
|
214
|
+
};
|
|
215
|
+
if ("adapterId" in snapshot) details.adapter_id = snapshot.adapterId;
|
|
216
|
+
if ("profileId" in snapshot) details.profile_id = snapshot.profileId;
|
|
217
|
+
if (snapshot.state === "stopped") details.stop_reason = snapshot.stopReason;
|
|
218
|
+
if (snapshot.state === "stopped" && snapshot.threadId !== undefined) {
|
|
219
|
+
details.thread_id = snapshot.threadId;
|
|
220
|
+
}
|
|
221
|
+
if (result.stackFrames !== undefined) {
|
|
222
|
+
details.stack_frame_ids = result.stackFrames.map((frame) => frame.id);
|
|
223
|
+
}
|
|
224
|
+
if (snapshot.state === "terminated" && snapshot.exitCode !== undefined) {
|
|
225
|
+
details.exit_code = snapshot.exitCode;
|
|
226
|
+
}
|
|
227
|
+
if (snapshot.state === "terminated" && snapshot.terminationReason !== undefined) {
|
|
228
|
+
details.termination_reason = snapshot.terminationReason;
|
|
229
|
+
}
|
|
230
|
+
const presentation =
|
|
231
|
+
executionWaitCancelled && isDapExecutionWaitOperation(operation)
|
|
232
|
+
? { kind: "execution_wait" as const, operation, cancelled: true as const }
|
|
233
|
+
: dapPresentationDetails(result);
|
|
234
|
+
if (presentation !== undefined) details.presentation = presentation;
|
|
235
|
+
return Value.Parse(DapToolResultDetailsSchema, details);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function formatDapToolResult(
|
|
239
|
+
operation: DapToolParameters["operation"],
|
|
240
|
+
result: DapSessionResult,
|
|
241
|
+
): string {
|
|
242
|
+
const { output, ...summary } = result;
|
|
243
|
+
const heading = `DAP ${operation}: ${JSON.stringify(summary)}`;
|
|
244
|
+
if (output.length === 0) return heading;
|
|
245
|
+
const discardNotice =
|
|
246
|
+
result.discardedOutputBytes === 0
|
|
247
|
+
? ""
|
|
248
|
+
: ` (${result.discardedOutputBytes} older bytes discarded)`;
|
|
249
|
+
return `${heading}\n\nDebuggee output${discardNotice}:\n${output}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function createDapToolOutput(
|
|
253
|
+
operation: DapToolParameters["operation"],
|
|
254
|
+
result: DapSessionResult,
|
|
255
|
+
sessionFiles: DapSessionFiles,
|
|
256
|
+
executionWaitCancelled: boolean,
|
|
257
|
+
): Promise<AgentToolResult<DapToolResultDetails>> {
|
|
258
|
+
const text = formatDapToolResult(operation, result);
|
|
259
|
+
const details = toolResultDetails(operation, result, executionWaitCancelled);
|
|
260
|
+
const truncation = truncateHead(text, {
|
|
261
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
262
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
263
|
+
});
|
|
264
|
+
if (!truncation.truncated) return { content: [{ type: "text", text }], details };
|
|
265
|
+
|
|
266
|
+
const spillPath = await sessionFiles.writeResultSpill(text);
|
|
267
|
+
const normalizedDetails = Value.Parse(DapToolResultDetailsSchema, {
|
|
268
|
+
...details,
|
|
269
|
+
output_truncated: true,
|
|
270
|
+
spill_path: spillPath,
|
|
271
|
+
});
|
|
272
|
+
return {
|
|
273
|
+
content: [
|
|
274
|
+
{
|
|
275
|
+
type: "text",
|
|
276
|
+
text: `${truncation.content}\n\n[Pi DAP: output truncated; complete Result Spill: ${spillPath}]`,
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
details: normalizedDetails,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function dispatchDapOperation(
|
|
284
|
+
parameters: DapToolParameters,
|
|
285
|
+
session: DapToolSession,
|
|
286
|
+
cwd: string,
|
|
287
|
+
signal: AbortSignal | undefined,
|
|
288
|
+
): Promise<DapSessionResult> {
|
|
289
|
+
switch (parameters.operation) {
|
|
290
|
+
case "launch": {
|
|
291
|
+
let input: DapLaunchInput = {};
|
|
292
|
+
if (parameters.profile !== undefined) input = { ...input, profile: parameters.profile };
|
|
293
|
+
if (parameters.program !== undefined) {
|
|
294
|
+
input = { ...input, program: resolve(cwd, parameters.program) };
|
|
295
|
+
}
|
|
296
|
+
if (parameters.args !== undefined) input = { ...input, args: parameters.args };
|
|
297
|
+
if (parameters.cwd !== undefined) input = { ...input, cwd: resolve(cwd, parameters.cwd) };
|
|
298
|
+
return session.launch(input, signal);
|
|
299
|
+
}
|
|
300
|
+
case "set_breakpoints":
|
|
301
|
+
return session.setBreakpoints(
|
|
302
|
+
{ filePath: resolve(cwd, parameters.file_path), breakpoints: parameters.breakpoints },
|
|
303
|
+
signal,
|
|
304
|
+
);
|
|
305
|
+
case "continue":
|
|
306
|
+
return session.continue(signal);
|
|
307
|
+
case "next":
|
|
308
|
+
return session.next(signal);
|
|
309
|
+
case "step_in":
|
|
310
|
+
return session.stepIn(signal);
|
|
311
|
+
case "step_out":
|
|
312
|
+
return session.stepOut(signal);
|
|
313
|
+
case "pause":
|
|
314
|
+
return session.pause(signal);
|
|
315
|
+
case "stack": {
|
|
316
|
+
let input: DapStackInput = {};
|
|
317
|
+
if (parameters.thread_id !== undefined) input = { ...input, threadId: parameters.thread_id };
|
|
318
|
+
if (parameters.start !== undefined) input = { ...input, start: parameters.start };
|
|
319
|
+
if (parameters.count !== undefined) input = { ...input, count: parameters.count };
|
|
320
|
+
return session.stack(input, signal);
|
|
321
|
+
}
|
|
322
|
+
case "variables": {
|
|
323
|
+
let page: Pick<DapVariablesInput, "start" | "count"> = {};
|
|
324
|
+
if (parameters.start !== undefined) page = { ...page, start: parameters.start };
|
|
325
|
+
if (parameters.count !== undefined) page = { ...page, count: parameters.count };
|
|
326
|
+
return "frame_id" in parameters
|
|
327
|
+
? session.variables({ ...page, frameId: parameters.frame_id }, signal)
|
|
328
|
+
: session.variables(
|
|
329
|
+
{ ...page, variablesReference: parameters.variables_reference },
|
|
330
|
+
signal,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
case "evaluate": {
|
|
334
|
+
let input: DapEvaluateInput = { expression: parameters.expression };
|
|
335
|
+
if (parameters.frame_id !== undefined) input = { ...input, frameId: parameters.frame_id };
|
|
336
|
+
return session.evaluate(input, signal);
|
|
337
|
+
}
|
|
338
|
+
case "status":
|
|
339
|
+
return session.status();
|
|
340
|
+
case "stop":
|
|
341
|
+
return session.stop();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function notifyDapToolObserver(operation: () => void): void {
|
|
346
|
+
try {
|
|
347
|
+
operation();
|
|
348
|
+
} catch {
|
|
349
|
+
// Observer UI failures cannot change model-facing Debug Session behavior.
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Create the single strict Pi DAP ToolDefinition bound to current session resources. */
|
|
354
|
+
export function createDapToolDefinition(
|
|
355
|
+
getRuntime: () => DapToolRuntime | undefined,
|
|
356
|
+
): ToolDefinition<typeof DapToolParametersSchema, DapToolRenderDetails> {
|
|
357
|
+
return {
|
|
358
|
+
name: "dap",
|
|
359
|
+
label: "DAP",
|
|
360
|
+
description:
|
|
361
|
+
"Launch and inspect one configured Debug Session through the Debug Adapter Protocol. Paths are relative to Pi's project directory. Output is limited to 2,000 lines or 50 KB; complete truncated output is saved as a Result Spill.",
|
|
362
|
+
promptSnippet: "Debug a program through one configured Debug Session",
|
|
363
|
+
promptGuidelines: [
|
|
364
|
+
"Use dap to set source breakpoints, launch a configured Debug Session, control the Debuggee, and inspect stopped Stack Frames and variables.",
|
|
365
|
+
],
|
|
366
|
+
parameters: DapToolParametersSchema,
|
|
367
|
+
renderCall: (argumentsValue, theme, context) =>
|
|
368
|
+
renderDapToolCall(argumentsValue, theme, context.expanded, context.cwd),
|
|
369
|
+
renderResult: (result, options, theme, context) =>
|
|
370
|
+
renderDapToolResult(result, options, theme, context.isError, context.cwd),
|
|
371
|
+
async execute(_toolCallId, input, signal, onUpdate, context) {
|
|
372
|
+
const parameters = parseDapToolParameters(input);
|
|
373
|
+
const runtime = getRuntime();
|
|
374
|
+
if (runtime === undefined) throw piDapError("Pi conversation session is not active");
|
|
375
|
+
notifyDapToolObserver(() => runtime.observer?.onToolStart(parameters));
|
|
376
|
+
const startedAt = Date.now();
|
|
377
|
+
const updateProgress = () => {
|
|
378
|
+
if (!isDapExecutionWaitOperation(parameters.operation)) return;
|
|
379
|
+
onUpdate?.({
|
|
380
|
+
content: [{ type: "text", text: `${parameters.operation} waiting` }],
|
|
381
|
+
details: {
|
|
382
|
+
kind: "progress",
|
|
383
|
+
operation: parameters.operation,
|
|
384
|
+
elapsed_ms: Date.now() - startedAt,
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
};
|
|
388
|
+
updateProgress();
|
|
389
|
+
const progressInterval = isDapExecutionWaitOperation(parameters.operation)
|
|
390
|
+
? setInterval(updateProgress, 1_000)
|
|
391
|
+
: undefined;
|
|
392
|
+
progressInterval?.unref?.();
|
|
393
|
+
try {
|
|
394
|
+
const result = await dispatchDapOperation(parameters, runtime.session, context.cwd, signal);
|
|
395
|
+
const output = await createDapToolOutput(
|
|
396
|
+
parameters.operation,
|
|
397
|
+
result,
|
|
398
|
+
runtime.sessionFiles,
|
|
399
|
+
isDapExecutionWaitOperation(parameters.operation) && signal?.aborted === true,
|
|
400
|
+
);
|
|
401
|
+
notifyDapToolObserver(() => runtime.observer?.onToolSuccess(parameters, result));
|
|
402
|
+
return output;
|
|
403
|
+
} catch (cause) {
|
|
404
|
+
const error = piDapError(cause);
|
|
405
|
+
notifyDapToolObserver(() => runtime.observer?.onToolFailure(parameters, error));
|
|
406
|
+
throw error;
|
|
407
|
+
} finally {
|
|
408
|
+
if (progressInterval !== undefined) clearInterval(progressInterval);
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Register exactly one strict `dap` tool whose runtime follows Pi session reloads. */
|
|
415
|
+
export function registerDapTool(
|
|
416
|
+
pi: Pick<ExtensionAPI, "registerTool">,
|
|
417
|
+
getRuntime: () => DapToolRuntime | undefined,
|
|
418
|
+
): void {
|
|
419
|
+
pi.registerTool(createDapToolDefinition(getRuntime));
|
|
420
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./pi-dap-extension.js";
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getAgentDir,
|
|
3
|
+
SettingsManager,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type ExtensionContext,
|
|
6
|
+
type ExtensionFactory,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { DapSession } from "./dap-session.js";
|
|
9
|
+
import { createDapSessionFiles, type DapSessionFiles } from "./dap-session-files.js";
|
|
10
|
+
import { DapObserverUiController } from "./dap-observer-ui.js";
|
|
11
|
+
import { registerDapTool, type DapToolRuntime } from "./dap-tool.js";
|
|
12
|
+
import { resolveDapSettings } from "./pi-dap-settings.js";
|
|
13
|
+
|
|
14
|
+
/** Runtime construction effect kept narrow so lifecycle tests use an isolated Pi agent directory. */
|
|
15
|
+
export interface PiDapLifecycleEffects {
|
|
16
|
+
/** Return Pi's trust-aware global settings directory. */
|
|
17
|
+
getAgentDirectory(): string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ActivePiDapSession extends DapToolRuntime {
|
|
21
|
+
readonly observer: DapObserverUiController;
|
|
22
|
+
readonly session: DapSession;
|
|
23
|
+
readonly sessionFiles: DapSessionFiles;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const productionPiDapLifecycleEffects: PiDapLifecycleEffects = {
|
|
27
|
+
getAgentDirectory: getAgentDir,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Own settings, one Debug Session, one registered tool, and cleanup for a Pi conversation session. */
|
|
31
|
+
export class PiDapLifecycleController {
|
|
32
|
+
private activeSession: ActivePiDapSession | undefined;
|
|
33
|
+
private shutdownPromise: Promise<void> | undefined;
|
|
34
|
+
private toolRegistered = false;
|
|
35
|
+
|
|
36
|
+
/** Bind lifecycle handlers to Pi without starting a Debug Adapter. */
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly pi: ExtensionAPI,
|
|
39
|
+
private readonly effects: PiDapLifecycleEffects,
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
/** Register the Pi conversation session start and shutdown handlers. */
|
|
43
|
+
register(): void {
|
|
44
|
+
this.pi.on("session_start", (_event, context) => this.startSession(context));
|
|
45
|
+
this.pi.on("session_shutdown", () => this.shutdownSession());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async startSession(context: ExtensionContext): Promise<void> {
|
|
49
|
+
await this.shutdownSession();
|
|
50
|
+
const settingsManager = SettingsManager.create(context.cwd, this.effects.getAgentDirectory(), {
|
|
51
|
+
projectTrusted: context.isProjectTrusted(),
|
|
52
|
+
});
|
|
53
|
+
const settings = resolveDapSettings(settingsManager);
|
|
54
|
+
if (settings.warnings.length > 0) {
|
|
55
|
+
context.ui.notify(`Pi DAP settings:\n- ${settings.warnings.join("\n- ")}`, "warning");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sessionFiles = await createDapSessionFiles(context.sessionManager.getSessionDir());
|
|
59
|
+
const observer = new DapObserverUiController(context);
|
|
60
|
+
this.activeSession = {
|
|
61
|
+
observer,
|
|
62
|
+
session: new DapSession({
|
|
63
|
+
cwd: context.cwd,
|
|
64
|
+
settings,
|
|
65
|
+
sessionFiles,
|
|
66
|
+
onSnapshotChange: (snapshot) => observer.onSessionSnapshot(snapshot),
|
|
67
|
+
onUnexpectedFailure: (error) => observer.onUnexpectedFailure(error),
|
|
68
|
+
}),
|
|
69
|
+
sessionFiles,
|
|
70
|
+
};
|
|
71
|
+
if (!this.toolRegistered) {
|
|
72
|
+
registerDapTool(this.pi, () => this.activeSession);
|
|
73
|
+
this.toolRegistered = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async shutdownSession(): Promise<void> {
|
|
78
|
+
if (this.activeSession === undefined) {
|
|
79
|
+
await this.shutdownPromise;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const activeSession = this.activeSession;
|
|
83
|
+
this.activeSession = undefined;
|
|
84
|
+
const shutdown = (async () => {
|
|
85
|
+
activeSession.observer.dispose();
|
|
86
|
+
try {
|
|
87
|
+
await activeSession.session.shutdown();
|
|
88
|
+
} finally {
|
|
89
|
+
await activeSession.sessionFiles.close();
|
|
90
|
+
}
|
|
91
|
+
})();
|
|
92
|
+
this.shutdownPromise = shutdown;
|
|
93
|
+
try {
|
|
94
|
+
await shutdown;
|
|
95
|
+
} finally {
|
|
96
|
+
if (this.shutdownPromise === shutdown) this.shutdownPromise = undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Compose the source-TypeScript Pi DAP extension without starting a Debug Adapter at load time. */
|
|
102
|
+
export function createPiDapExtension(
|
|
103
|
+
effects: PiDapLifecycleEffects = productionPiDapLifecycleEffects,
|
|
104
|
+
): ExtensionFactory {
|
|
105
|
+
return (pi) => new PiDapLifecycleController(pi, effects).register();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const piDapExtension = createPiDapExtension();
|
|
109
|
+
|
|
110
|
+
export default piDapExtension;
|