@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ian Pascoe
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# @ian-pascoe/pi-dap
|
|
2
|
+
|
|
3
|
+
Configured [Debug Adapter Protocol](https://microsoft.github.io/debug-adapter-protocol/)
|
|
4
|
+
(DAP) sessions for [Pi](https://github.com/earendil-works/pi).
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pi install npm:@ian-pascoe/pi-dap
|
|
10
|
+
# or
|
|
11
|
+
pi install git:github.com/ian-pascoe/pi-extensions
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
For a local checkout, run `pi -e ./packages/pi-dap/src/index.ts`.
|
|
15
|
+
|
|
16
|
+
Debug Adapter executables are user-managed. Pi DAP has no adapter discovery,
|
|
17
|
+
installer, or catalog. The repository-only `vscode-js-debug` development
|
|
18
|
+
dependency supports this repository's Node smoke test; its files are not packed
|
|
19
|
+
or installed with the package.
|
|
20
|
+
|
|
21
|
+
## Settings
|
|
22
|
+
|
|
23
|
+
Pi DAP reads only the `dap` key from Pi's global `settings.json` and trusted
|
|
24
|
+
project `.pi/settings.json`:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"dap": {
|
|
29
|
+
"timeouts": {
|
|
30
|
+
"startupMs": 10000,
|
|
31
|
+
"requestMs": 10000,
|
|
32
|
+
"executionMs": 30000,
|
|
33
|
+
"shutdownMs": 5000
|
|
34
|
+
},
|
|
35
|
+
"adapters": {
|
|
36
|
+
"node": {
|
|
37
|
+
"command": "node",
|
|
38
|
+
"args": ["/absolute/path/to/dapDebugServer.js", "$PORT", "127.0.0.1"],
|
|
39
|
+
"environment": {},
|
|
40
|
+
"transport": {
|
|
41
|
+
"type": "tcp",
|
|
42
|
+
"host": "127.0.0.1",
|
|
43
|
+
"port": 0
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"profiles": {
|
|
48
|
+
"node": {
|
|
49
|
+
"adapter": "node",
|
|
50
|
+
"arguments": {
|
|
51
|
+
"type": "pwa-node",
|
|
52
|
+
"console": "internalConsole",
|
|
53
|
+
"stopOnEntry": true
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
An Adapter Definition needs a non-empty `command` and `transport`; `args` and
|
|
62
|
+
`environment` default to an empty array and object, respectively. `transport` is either `"stdio"` or a
|
|
63
|
+
TCP object with `type: "tcp"`; its host defaults to `127.0.0.1`, and a missing
|
|
64
|
+
or zero port selects a local port. TCP arguments may use `$PORT` anywhere in an
|
|
65
|
+
argument, and Pi DAP also supplies that selected port as `PORT` in the adapter
|
|
66
|
+
environment. `$PORT` is invalid for stdio adapters. Environment strings overlay
|
|
67
|
+
the inherited environment; `null` removes an inherited variable.
|
|
68
|
+
|
|
69
|
+
A Launch Profile needs an existing Adapter Definition ID and opaque JSON-object
|
|
70
|
+
`arguments`. Global and trusted-project timeouts merge by field. Adapter and
|
|
71
|
+
profile maps merge by ID: a project entry replaces the complete global entry;
|
|
72
|
+
`null` removes it. Invalid project replacements still shadow global entries.
|
|
73
|
+
Invalid entries are quarantined independently and produce path-qualified
|
|
74
|
+
warnings at session start, while unrelated valid entries stay available.
|
|
75
|
+
Untrusted project settings are ignored. Use Pi `/reload` to reload settings.
|
|
76
|
+
|
|
77
|
+
### Node and TypeScript
|
|
78
|
+
|
|
79
|
+
Node/TypeScript through Microsoft `vscode-js-debug` is the Supported Adapter
|
|
80
|
+
workflow. For example, set the Node adapter command to `node` and point its TCP
|
|
81
|
+
arguments at `dapDebugServer.js` followed by `$PORT`, as above. Other
|
|
82
|
+
standards-based adapters are Experimental: they may work through DAP but have
|
|
83
|
+
no compatibility promise.
|
|
84
|
+
|
|
85
|
+
## `dap` tool
|
|
86
|
+
|
|
87
|
+
Pi DAP registers one strict `dap` tool with exactly these operations:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
launch set_breakpoints continue next
|
|
91
|
+
step_in step_out pause stack
|
|
92
|
+
variables evaluate status stop
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`launch` selects a profile (it may be omitted only when exactly one valid
|
|
96
|
+
profile exists). `program`, `args`, and `cwd` replace the same profile
|
|
97
|
+
arguments; relative `program` and `cwd` paths resolve from Pi's project working
|
|
98
|
+
directory. A Debug Session is single-active: launching while one is active
|
|
99
|
+
fails. Desired Breakpoints are complete per-file lists and survive `stop` and
|
|
100
|
+
later launches in the same Pi conversation session; `[]` clears a file.
|
|
101
|
+
Relative breakpoint paths also resolve from Pi's project working directory.
|
|
102
|
+
|
|
103
|
+
Execution and inspection require a stopped Debuggee: `continue`, `next`,
|
|
104
|
+
`step_in`, `step_out`, `stack`, `variables`, and `evaluate`. `pause` requires a
|
|
105
|
+
running Debuggee. `status` and `stop` are idempotent. `stack` defaults to the
|
|
106
|
+
stopped thread, offset `0`, and count `20`; `variables` takes exactly one
|
|
107
|
+
`frame_id` or `variables_reference` and defaults its count to `100`; `evaluate`
|
|
108
|
+
defaults to the top Stack Frame.
|
|
109
|
+
|
|
110
|
+
Execution waits end on a stop, exit, cancellation, or `executionMs`; an
|
|
111
|
+
execution timeout reports `running`. Request, startup, and shutdown timeouts
|
|
112
|
+
are errors. A natural exit leaves a terminal snapshot available from `status`
|
|
113
|
+
until the next launch.
|
|
114
|
+
|
|
115
|
+
## Output and lifecycle
|
|
116
|
+
|
|
117
|
+
Each successful operation drains currently unread Debuggee output. Pi DAP
|
|
118
|
+
retains at most 1 MiB of unread output, reporting discarded older bytes. Tool
|
|
119
|
+
text follows Pi's 2,000-line/50-KB visible limit; when truncated, the retained
|
|
120
|
+
complete result is written to a Result Spill and its path appears in the
|
|
121
|
+
result. Adapter stderr retains its newest 1 MiB in the session directory and
|
|
122
|
+
process or protocol failures name that path.
|
|
123
|
+
|
|
124
|
+
Adapters start lazily at `launch`, use the project working directory, and are
|
|
125
|
+
owned by one Pi conversation session. `stop`, launch cancellation, adapter
|
|
126
|
+
failure, and session shutdown attempt DAP termination and disconnect before
|
|
127
|
+
terminating owned Linux process groups. Session shutdown removes session files.
|
|
128
|
+
Cancelling an execution wait only ends that wait; the live Debug Session remains
|
|
129
|
+
recoverable.
|
|
130
|
+
|
|
131
|
+
### Observer UI
|
|
132
|
+
|
|
133
|
+
In TUI mode, calls and results use compact semantic transcript rows. Expanding a
|
|
134
|
+
row shows only explicitly supplied arguments and bounded Breakpoint, Stack Frame,
|
|
135
|
+
variable, or evaluation details. Long execution waits update once per second.
|
|
136
|
+
Malformed or historical rows fall back to their original tool text.
|
|
137
|
+
|
|
138
|
+
One widget above the editor follows launching, running, stopped, and terminated
|
|
139
|
+
activity. It is derived only from lifecycle transitions and successful results
|
|
140
|
+
Pi DAP has already received; it sends no additional DAP request and provides no
|
|
141
|
+
human debugger controls. Stopped source locations clear on resume. The terminal
|
|
142
|
+
snapshot remains for ten seconds, while idle sessions have no widget. RPC, JSON,
|
|
143
|
+
and print modes do not mount it.
|
|
144
|
+
|
|
145
|
+
The model still receives the unchanged raw `DAP <operation>: <JSON>` text,
|
|
146
|
+
Debuggee output, truncation, and Result Spill notice. Only the human-visible copy
|
|
147
|
+
of Debuggee output is stripped of terminal sequences and unsafe controls; the raw
|
|
148
|
+
tool result and Result Spill retain the original bytes.
|
|
149
|
+
|
|
150
|
+
## V1 boundary
|
|
151
|
+
|
|
152
|
+
V1 supports configured stdio and TCP adapters on Linux, one active Debug
|
|
153
|
+
Session, source breakpoints, core execution control, stack/variables/evaluation,
|
|
154
|
+
and headless `runInTerminal`. The Supported `vscode-js-debug` workflow uses one
|
|
155
|
+
adapter-owned primary target channel; it is not a second model-facing Debug
|
|
156
|
+
Session, and unrelated, second, or nested `startDebugging` requests are rejected.
|
|
157
|
+
V1 excludes attach, restart, function/data/instruction breakpoints, hit counts,
|
|
158
|
+
logpoints, memory, disassembly, modules, user-requested child Debug Sessions, raw
|
|
159
|
+
DAP requests, `launch.json`, WebSocket, persistence, and a directly operated
|
|
160
|
+
debugger UI.
|
|
161
|
+
|
|
162
|
+
Trusted project settings can run arbitrary local executables with Pi's
|
|
163
|
+
permissions. Review adapter commands and configuration before trusting a
|
|
164
|
+
project.
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ian-pascoe/pi-dap",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Configured Debug Adapter Protocol sessions for Pi",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"dap",
|
|
8
|
+
"debug-adapter-protocol",
|
|
9
|
+
"pi",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi-package"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/ian-pascoe/pi-extensions#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/ian-pascoe/pi-extensions/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Ian Pascoe <ian.g.pascoe@gmail.com>",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/ian-pascoe/pi-extensions.git",
|
|
22
|
+
"directory": "packages/pi-dap"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public",
|
|
32
|
+
"provenance": true
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "vitest run --config ../../vitest.config.ts --root .",
|
|
36
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@vscode/debugprotocol": "^1.68.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"vscode-js-debug": "https://github.com/microsoft/vscode-js-debug/releases/download/v1.117.0/js-debug-dap-v1.117.0.tar.gz"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
46
|
+
"@earendil-works/pi-tui": "*",
|
|
47
|
+
"typebox": "*"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=22.19.0"
|
|
51
|
+
},
|
|
52
|
+
"pi": {
|
|
53
|
+
"extensions": [
|
|
54
|
+
"./src/index.ts"
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import type { ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
sliceByColumn,
|
|
4
|
+
truncateToWidth,
|
|
5
|
+
visibleWidth,
|
|
6
|
+
type Component,
|
|
7
|
+
type TUI,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
9
|
+
import type { DapSessionResult, DapSessionSnapshot } from "./dap-session.js";
|
|
10
|
+
import type { DapToolParameters } from "./dap-tool-contract.js";
|
|
11
|
+
import { workspaceRelativeDapPath } from "./dap-tool-rendering.js";
|
|
12
|
+
import type { DapToolObserver } from "./dap-tool.js";
|
|
13
|
+
|
|
14
|
+
const DAP_OBSERVER_UI_KEY = "pi-dap";
|
|
15
|
+
const DAP_OBSERVER_REFRESH_MS = 1_000;
|
|
16
|
+
const DAP_OBSERVER_TERMINAL_COOLDOWN_MS = 10_000;
|
|
17
|
+
const DAP_OBSERVER_SEPARATOR = " ";
|
|
18
|
+
|
|
19
|
+
/** Theme operations used by the one-line Pi DAP Observer widget. */
|
|
20
|
+
export type DapObserverWidgetTheme = Pick<Theme, "bold" | "fg">;
|
|
21
|
+
|
|
22
|
+
/** Projected values rendered by the non-authoritative Observer widget. */
|
|
23
|
+
export interface DapObserverWidgetView {
|
|
24
|
+
/** Current observed lifecycle state; idle has no widget view. */
|
|
25
|
+
readonly state: "launching" | "running" | "stopped" | "terminated";
|
|
26
|
+
readonly adapterId?: string;
|
|
27
|
+
readonly profileId?: string;
|
|
28
|
+
readonly stopReason?: string;
|
|
29
|
+
/** Current stopped Stack Frame location, falling back to the explicitly launched program. */
|
|
30
|
+
readonly path?: string;
|
|
31
|
+
/** Milliseconds since launch, frozen when termination is observed. */
|
|
32
|
+
readonly elapsedMs?: number;
|
|
33
|
+
readonly exitCode?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Narrow Pi context needed to mount and notify the Observer UI. */
|
|
37
|
+
export interface DapObserverUiContext {
|
|
38
|
+
readonly mode: "tui" | "rpc" | "json" | "print";
|
|
39
|
+
readonly cwd: string;
|
|
40
|
+
readonly ui: Pick<ExtensionUIContext, "notify" | "setWidget">;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface DapObserverStateParts {
|
|
44
|
+
readonly full: string;
|
|
45
|
+
readonly short: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function dapObserverStateParts(
|
|
49
|
+
view: DapObserverWidgetView,
|
|
50
|
+
theme: DapObserverWidgetTheme,
|
|
51
|
+
): DapObserverStateParts {
|
|
52
|
+
switch (view.state) {
|
|
53
|
+
case "launching":
|
|
54
|
+
return {
|
|
55
|
+
full: theme.fg("accent", "▶ launching"),
|
|
56
|
+
short: theme.fg("accent", "▶ launching"),
|
|
57
|
+
};
|
|
58
|
+
case "running":
|
|
59
|
+
return {
|
|
60
|
+
full: theme.fg("accent", "▶ running"),
|
|
61
|
+
short: theme.fg("accent", "▶ running"),
|
|
62
|
+
};
|
|
63
|
+
case "stopped": {
|
|
64
|
+
const short = theme.fg("accent", "● stopped");
|
|
65
|
+
return {
|
|
66
|
+
full:
|
|
67
|
+
view.stopReason === undefined
|
|
68
|
+
? short
|
|
69
|
+
: `${short}${theme.fg("dim", ` · ${view.stopReason}`)}`,
|
|
70
|
+
short,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
case "terminated": {
|
|
74
|
+
const short = theme.fg("success", "■ terminated");
|
|
75
|
+
return {
|
|
76
|
+
full:
|
|
77
|
+
view.exitCode === undefined
|
|
78
|
+
? short
|
|
79
|
+
: `${short}${theme.fg("dim", ` · exit ${view.exitCode}`)}`,
|
|
80
|
+
short,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatDapObserverDuration(elapsedMs: number | undefined): string | undefined {
|
|
87
|
+
if (elapsedMs === undefined) return undefined;
|
|
88
|
+
const seconds = Math.max(0, Math.floor(elapsedMs / 1_000));
|
|
89
|
+
if (seconds < 60) return `${seconds}s`;
|
|
90
|
+
const minutes = Math.floor(seconds / 60);
|
|
91
|
+
return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function joinDapObserverParts(parts: readonly (string | undefined)[]): string {
|
|
95
|
+
return parts.filter((part): part is string => part !== undefined).join(DAP_OBSERVER_SEPARATOR);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Render one responsive Observer snapshot without exceeding the terminal width. */
|
|
99
|
+
export function renderDapObserverWidgetLine(
|
|
100
|
+
view: DapObserverWidgetView,
|
|
101
|
+
width: number,
|
|
102
|
+
theme: DapObserverWidgetTheme,
|
|
103
|
+
): string {
|
|
104
|
+
if (width <= 0) return "";
|
|
105
|
+
const title = theme.fg("toolTitle", theme.bold("DAP"));
|
|
106
|
+
const state = dapObserverStateParts(view, theme);
|
|
107
|
+
const profile =
|
|
108
|
+
view.adapterId === undefined && view.profileId === undefined
|
|
109
|
+
? undefined
|
|
110
|
+
: theme.fg("muted", `${view.adapterId ?? "?"}/${view.profileId ?? "?"}`);
|
|
111
|
+
const path = view.path === undefined ? undefined : theme.fg("muted", view.path);
|
|
112
|
+
const durationValue = formatDapObserverDuration(view.elapsedMs);
|
|
113
|
+
const duration = durationValue === undefined ? undefined : theme.fg("muted", durationValue);
|
|
114
|
+
const candidates = [
|
|
115
|
+
joinDapObserverParts([title, state.full, profile, path, duration]),
|
|
116
|
+
joinDapObserverParts([title, state.full, profile, path]),
|
|
117
|
+
joinDapObserverParts([title, state.full, profile]),
|
|
118
|
+
joinDapObserverParts([title, state.full]),
|
|
119
|
+
joinDapObserverParts([title, state.short]),
|
|
120
|
+
];
|
|
121
|
+
const fitting = candidates.find((candidate) => visibleWidth(candidate) <= width);
|
|
122
|
+
if (fitting !== undefined) return fitting;
|
|
123
|
+
|
|
124
|
+
if (visibleWidth(title) >= width) return truncateToWidth(title, width, "…");
|
|
125
|
+
const stateWidth = Math.max(
|
|
126
|
+
0,
|
|
127
|
+
width - visibleWidth(title) - visibleWidth(DAP_OBSERVER_SEPARATOR),
|
|
128
|
+
);
|
|
129
|
+
const shortenedState = sliceByColumn(state.short, 0, stateWidth, true);
|
|
130
|
+
return truncateToWidth(joinDapObserverParts([title, shortenedState || undefined]), width, "…");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class DapObserverWidgetComponent implements Component {
|
|
134
|
+
constructor(
|
|
135
|
+
private view: DapObserverWidgetView,
|
|
136
|
+
private readonly tui: TUI,
|
|
137
|
+
private readonly theme: DapObserverWidgetTheme,
|
|
138
|
+
) {}
|
|
139
|
+
|
|
140
|
+
update(view: DapObserverWidgetView): void {
|
|
141
|
+
this.view = view;
|
|
142
|
+
this.tui.requestRender();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
render(width: number): string[] {
|
|
146
|
+
return [renderDapObserverWidgetLine(this.view, width, this.theme)];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
invalidate(): void {}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Own one TUI-only Observer snapshot, widget, refresh interval, cooldown, and cleanup. */
|
|
153
|
+
export class DapObserverUiController implements DapToolObserver {
|
|
154
|
+
private activeToolCalls = 0;
|
|
155
|
+
private disposed = false;
|
|
156
|
+
private launchStartedAt: number | undefined;
|
|
157
|
+
private terminalElapsedMs: number | undefined;
|
|
158
|
+
private launchedProgram: string | undefined;
|
|
159
|
+
private sourceLocation: string | undefined;
|
|
160
|
+
private snapshot: DapSessionSnapshot = { state: "idle" };
|
|
161
|
+
private currentView: DapObserverWidgetView | undefined;
|
|
162
|
+
private widgetComponent: DapObserverWidgetComponent | undefined;
|
|
163
|
+
private widgetMounted = false;
|
|
164
|
+
private refreshInterval: ReturnType<typeof setInterval> | undefined;
|
|
165
|
+
private cooldownTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
166
|
+
|
|
167
|
+
/** Construct an inert Observer UI; the first launch tool call mounts its widget. */
|
|
168
|
+
constructor(private readonly context: DapObserverUiContext) {}
|
|
169
|
+
|
|
170
|
+
/** Record tool activity without dispatching any Debug Adapter operation. */
|
|
171
|
+
onToolStart(parameters: DapToolParameters): void {
|
|
172
|
+
if (this.disposed) return;
|
|
173
|
+
this.activeToolCalls++;
|
|
174
|
+
if (parameters.operation !== "launch") return;
|
|
175
|
+
this.clearCooldown();
|
|
176
|
+
this.launchStartedAt = Date.now();
|
|
177
|
+
this.terminalElapsedMs = undefined;
|
|
178
|
+
this.launchedProgram =
|
|
179
|
+
parameters.program === undefined
|
|
180
|
+
? undefined
|
|
181
|
+
: workspaceRelativeDapPath(this.context.cwd, parameters.program);
|
|
182
|
+
this.sourceLocation = undefined;
|
|
183
|
+
this.snapshot = { state: "launching", adapterId: "?", profileId: parameters.profile ?? "?" };
|
|
184
|
+
this.ensureRefreshInterval();
|
|
185
|
+
this.refreshWidget();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Learn source location only from a successful result already returned by the tool. */
|
|
189
|
+
onToolSuccess(_parameters: DapToolParameters, result: DapSessionResult): void {
|
|
190
|
+
if (this.disposed) return;
|
|
191
|
+
this.activeToolCalls = Math.max(0, this.activeToolCalls - 1);
|
|
192
|
+
if (this.snapshot.state === "stopped") {
|
|
193
|
+
const frame = result.stackFrames?.[0];
|
|
194
|
+
const source = frame?.source?.path ?? frame?.source?.name;
|
|
195
|
+
if (frame !== undefined && source !== undefined) {
|
|
196
|
+
this.sourceLocation = `${workspaceRelativeDapPath(this.context.cwd, source)}:${frame.line}`;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
this.refreshWidget();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Complete failed tool activity while leaving model-facing failure behavior unchanged. */
|
|
203
|
+
onToolFailure(parameters: DapToolParameters, _error: Error): void {
|
|
204
|
+
if (this.disposed) return;
|
|
205
|
+
this.activeToolCalls = Math.max(0, this.activeToolCalls - 1);
|
|
206
|
+
if (parameters.operation === "launch" && this.snapshot.state === "launching") {
|
|
207
|
+
this.snapshot = { state: "idle" };
|
|
208
|
+
this.clearRefreshInterval();
|
|
209
|
+
this.hideWidget();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Project one actual Debug Session lifecycle transition into the Observer snapshot. */
|
|
214
|
+
onSessionSnapshot(snapshot: DapSessionSnapshot): void {
|
|
215
|
+
if (this.disposed) return;
|
|
216
|
+
if (snapshot.state !== "stopped" || this.snapshot.state !== "stopped") {
|
|
217
|
+
this.sourceLocation = undefined;
|
|
218
|
+
}
|
|
219
|
+
this.snapshot = snapshot;
|
|
220
|
+
if (snapshot.state === "idle") {
|
|
221
|
+
this.clearRefreshInterval();
|
|
222
|
+
this.clearCooldown();
|
|
223
|
+
this.hideWidget();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (snapshot.state === "terminated") {
|
|
227
|
+
this.terminalElapsedMs =
|
|
228
|
+
this.launchStartedAt === undefined ? undefined : Date.now() - this.launchStartedAt;
|
|
229
|
+
this.clearRefreshInterval();
|
|
230
|
+
this.refreshWidget();
|
|
231
|
+
this.ensureCooldown();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
this.clearCooldown();
|
|
235
|
+
this.ensureRefreshInterval();
|
|
236
|
+
this.refreshWidget();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Notify only an actionable asynchronous failure not represented by an active tool result. */
|
|
240
|
+
onUnexpectedFailure(error: Error): void {
|
|
241
|
+
if (this.disposed || this.activeToolCalls > 0) return;
|
|
242
|
+
const message = error.message.startsWith("Pi DAP:")
|
|
243
|
+
? error.message
|
|
244
|
+
: `Pi DAP: ${error.message}`;
|
|
245
|
+
this.context.ui.notify(message, "error");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Dispose timers and the widget immediately; repeated disposal is inert. */
|
|
249
|
+
dispose(): void {
|
|
250
|
+
if (this.disposed) return;
|
|
251
|
+
this.disposed = true;
|
|
252
|
+
this.clearRefreshInterval();
|
|
253
|
+
this.clearCooldown();
|
|
254
|
+
this.hideWidget();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private observerWidgetView(): DapObserverWidgetView | undefined {
|
|
258
|
+
if (this.snapshot.state === "idle") return undefined;
|
|
259
|
+
const elapsedMs =
|
|
260
|
+
this.snapshot.state === "terminated"
|
|
261
|
+
? this.terminalElapsedMs
|
|
262
|
+
: this.launchStartedAt === undefined
|
|
263
|
+
? undefined
|
|
264
|
+
: Date.now() - this.launchStartedAt;
|
|
265
|
+
const path = this.sourceLocation ?? this.launchedProgram;
|
|
266
|
+
let view: DapObserverWidgetView = { state: this.snapshot.state };
|
|
267
|
+
if ("adapterId" in this.snapshot) view = { ...view, adapterId: this.snapshot.adapterId };
|
|
268
|
+
if ("profileId" in this.snapshot) view = { ...view, profileId: this.snapshot.profileId };
|
|
269
|
+
if (this.snapshot.state === "stopped") {
|
|
270
|
+
view = { ...view, stopReason: this.snapshot.stopReason };
|
|
271
|
+
}
|
|
272
|
+
if (this.snapshot.state === "terminated" && this.snapshot.exitCode !== undefined) {
|
|
273
|
+
view = { ...view, exitCode: this.snapshot.exitCode };
|
|
274
|
+
}
|
|
275
|
+
if (path !== undefined) view = { ...view, path };
|
|
276
|
+
if (elapsedMs !== undefined) view = { ...view, elapsedMs };
|
|
277
|
+
return view;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private refreshWidget(): void {
|
|
281
|
+
if (this.context.mode !== "tui") return;
|
|
282
|
+
const view = this.observerWidgetView();
|
|
283
|
+
this.currentView = view;
|
|
284
|
+
if (view === undefined) {
|
|
285
|
+
this.hideWidget();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (this.widgetMounted) {
|
|
289
|
+
this.widgetComponent?.update(view);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
this.context.ui.setWidget(
|
|
293
|
+
DAP_OBSERVER_UI_KEY,
|
|
294
|
+
(tui, theme) => {
|
|
295
|
+
this.widgetComponent = new DapObserverWidgetComponent(this.currentView ?? view, tui, theme);
|
|
296
|
+
return this.widgetComponent;
|
|
297
|
+
},
|
|
298
|
+
{ placement: "aboveEditor" },
|
|
299
|
+
);
|
|
300
|
+
this.widgetMounted = true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private hideWidget(): void {
|
|
304
|
+
if (this.context.mode !== "tui" || !this.widgetMounted) return;
|
|
305
|
+
this.context.ui.setWidget(DAP_OBSERVER_UI_KEY, undefined);
|
|
306
|
+
this.widgetMounted = false;
|
|
307
|
+
this.widgetComponent = undefined;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private ensureRefreshInterval(): void {
|
|
311
|
+
if (this.context.mode !== "tui" || this.refreshInterval !== undefined) return;
|
|
312
|
+
this.refreshInterval = setInterval(() => this.refreshWidget(), DAP_OBSERVER_REFRESH_MS);
|
|
313
|
+
this.refreshInterval.unref?.();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private clearRefreshInterval(): void {
|
|
317
|
+
if (this.refreshInterval === undefined) return;
|
|
318
|
+
clearInterval(this.refreshInterval);
|
|
319
|
+
this.refreshInterval = undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private ensureCooldown(): void {
|
|
323
|
+
if (this.context.mode !== "tui") return;
|
|
324
|
+
this.clearCooldown();
|
|
325
|
+
this.cooldownTimeout = setTimeout(() => {
|
|
326
|
+
this.cooldownTimeout = undefined;
|
|
327
|
+
this.hideWidget();
|
|
328
|
+
}, DAP_OBSERVER_TERMINAL_COOLDOWN_MS);
|
|
329
|
+
this.cooldownTimeout.unref?.();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private clearCooldown(): void {
|
|
333
|
+
if (this.cooldownTimeout === undefined) return;
|
|
334
|
+
clearTimeout(this.cooldownTimeout);
|
|
335
|
+
this.cooldownTimeout = undefined;
|
|
336
|
+
}
|
|
337
|
+
}
|