pi-ui-extend 0.1.6 → 0.1.8
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/dist/app/extension-ui-controller.js +1 -0
- package/dist/app/session-lifecycle-controller.js +3 -3
- package/external/pi-tools-suite/README.md +4 -50
- package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +0 -1
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +1 -2
- package/external/pi-tools-suite/src/index.ts +0 -1
- package/external/pi-tools-suite/src/lsp/_shared/config.ts +38 -13
- package/external/pi-tools-suite/src/lsp/_shared/paths.ts +11 -1
- package/external/pi-tools-suite/src/lsp/async.ts +6 -1
- package/external/pi-tools-suite/src/lsp/child-process.ts +16 -2
- package/external/pi-tools-suite/src/lsp/client.ts +183 -4
- package/external/pi-tools-suite/src/lsp/manager.ts +44 -5
- package/external/pi-tools-suite/src/lsp/markdown-diagnostics.ts +157 -0
- package/package.json +1 -1
- package/external/pi-tools-suite/src/terminal-bell/index.ts +0 -339
|
@@ -67,6 +67,9 @@ export class AppSessionLifecycleController {
|
|
|
67
67
|
async bindCurrentSession() {
|
|
68
68
|
const runtime = this.requireRuntime();
|
|
69
69
|
this.unsubscribe?.();
|
|
70
|
+
this.unsubscribe = runtime.session.subscribe((event) => {
|
|
71
|
+
this.host.handleSessionEvent(event);
|
|
72
|
+
});
|
|
70
73
|
this.host.closeSdkMenuForBind();
|
|
71
74
|
this.host.clearExtensionWidgets();
|
|
72
75
|
await runtime.session.bindExtensions({
|
|
@@ -75,9 +78,6 @@ export class AppSessionLifecycleController {
|
|
|
75
78
|
shutdownHandler: this.host.extensionShutdownHandler(),
|
|
76
79
|
onError: (error) => this.host.handleExtensionError(error),
|
|
77
80
|
});
|
|
78
|
-
this.unsubscribe = runtime.session.subscribe((event) => {
|
|
79
|
-
this.host.handleSessionEvent(event);
|
|
80
|
-
});
|
|
81
81
|
}
|
|
82
82
|
unsubscribeSession() {
|
|
83
83
|
this.unsubscribe?.();
|
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Local all-in-one Pi extension package.
|
|
4
4
|
|
|
5
|
-
This package keeps
|
|
5
|
+
This package keeps shared Pi tools as ordinary source folders under `src/` and registers them through one entrypoint.
|
|
6
6
|
|
|
7
7
|
- `src/ast-grep` — `ast_grep` / `ast_apply`
|
|
8
8
|
- `src/async-subagents` — `subagents` tool and sub-agent slash commands, including oh-my-openagent-style `/ultrawork` (`/ulw`) and `/hyperplan` orchestration prompts, plus config-defined sub-agent model/thinking/args presets selected via `/subagent-preset` from `asyncSubagents` in `~/.config/pi/pi-tools-suite.jsonc`; includes the `frontend` profile for Gemini-friendly UI/UX and visual frontend work plus the `vision` profile for screenshot/image description via `openai-codex/gpt-5.4-mini`; enforces a 30-minute per-agent execution timeout, project-wide `maxConcurrent` queueing, optional retry/backoff, and `result.json` structured metadata/chaining fields next to raw `result.md`; stores project-local run files and a registry under `.pi/subagents/` so result/status collection can recover after compaction or reload while the main session remains alive
|
|
9
|
-
- `src/terminal-bell` — terminal bell, macOS attention sound, and best-effort OS notification when the main agent session returns to idle; defers the alert while sub-agents are still running or the main agent is waiting for sub-agent results
|
|
10
9
|
- `src/lsp` — shared LSP diagnostics hook/library that enriches mutating tool results with diagnostics and shuts down language servers on session shutdown
|
|
11
10
|
- `src/repo-discovery` — `/idx-init`, `/idx-update`, and indexed-only `repo_architecture` / `repo_structure` / `repo_ast` / `repo_search` / `repo_explain` / `repo_deps`; tools register only when the launch project has `.indexer-cli`
|
|
12
11
|
- `src/antigravity-auth` — `antigravity` custom provider with Google Antigravity OAuth login, startup account list, `/antigravity-import` credential migration from opencode, `/antigravity-add-account` OAuth append into rotation, `/antigravity-account` status display, account rotation/failover, Antigravity plus Gemini CLI model registration, and streaming through the Cloud Code Assist unified gateway
|
|
@@ -19,7 +18,7 @@ This package keeps the former standalone extensions as ordinary source folders u
|
|
|
19
18
|
|
|
20
19
|
`index.ts` is intentionally only a thin auto-discovery shim that re-exports `src/index.ts`. There is no `pi.extensions` manifest here, so local Pi auto-discovery loads the suite once via `~/.pi/agent/extensions/pi-tools-suite/index.ts` and does not double-register tools.
|
|
21
20
|
|
|
22
|
-
Registration order is preserved in `src/index.ts`: ast-grep, async-subagents,
|
|
21
|
+
Registration order is preserved in `src/index.ts`: ast-grep, async-subagents, lsp, repo-discovery command/tool gate, antigravity-auth provider, todo, model-tools, usage, web-search, dcp, then prompt-commands. Tool metadata and active model-specific tool sets have two modes: standard and repo-aware. When `.indexer-cli` enables `repo_*`, those tools stay active ahead of overlapping lower-level aliases so the indexed discovery surface has priority.
|
|
23
22
|
|
|
24
23
|
## Disabling modules
|
|
25
24
|
|
|
@@ -27,14 +26,14 @@ Disable suite modules without editing `src/index.ts` via config or environment v
|
|
|
27
26
|
|
|
28
27
|
```jsonc
|
|
29
28
|
{
|
|
30
|
-
"disabledModules": ["
|
|
29
|
+
"disabledModules": ["web-search"]
|
|
31
30
|
}
|
|
32
31
|
```
|
|
33
32
|
|
|
34
33
|
Environment overrides are applied last:
|
|
35
34
|
|
|
36
35
|
```bash
|
|
37
|
-
PI_TOOLS_SUITE_DISABLED_MODULES=
|
|
36
|
+
PI_TOOLS_SUITE_DISABLED_MODULES=web-search pi ...
|
|
38
37
|
PI_TOOLS_SUITE_DISABLED=1 pi ... # disables all pi-tools-suite modules
|
|
39
38
|
```
|
|
40
39
|
|
|
@@ -152,50 +151,6 @@ Troubleshooting:
|
|
|
152
151
|
|
|
153
152
|
Do not send secrets, tokens, private repository text, or credential-bearing URLs through these tools; Ollama may query external web services to satisfy the request.
|
|
154
153
|
|
|
155
|
-
## Terminal bell / idle alert
|
|
156
|
-
|
|
157
|
-
`src/terminal-bell` alerts the user when the main Pi agent returns to idle. It does not alert while sub-agents are still running or while the main agent is waiting for sub-agent results.
|
|
158
|
-
|
|
159
|
-
Disable it entirely for headless runs:
|
|
160
|
-
|
|
161
|
-
```bash
|
|
162
|
-
HEADLESS=1 pi ...
|
|
163
|
-
# or
|
|
164
|
-
PI_TERMINAL_BELL_DISABLED=1 pi ...
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
Common environment options:
|
|
168
|
-
|
|
169
|
-
| Variable | Effect |
|
|
170
|
-
| --- | --- |
|
|
171
|
-
| `PI_TERMINAL_BELL=0` | Disable terminal `\x07` bell only |
|
|
172
|
-
| `PI_TERMINAL_BELL_FORCE=1` | Emit terminal bell even without TTY |
|
|
173
|
-
| `PI_TERMINAL_BELL_DELAY_MS=250` | Delay before alerting after idle |
|
|
174
|
-
| `PI_TERMINAL_BELL_SOUND=0` | Disable macOS `afplay` attention sound |
|
|
175
|
-
| `PI_TERMINAL_BELL_SOUND=Glass` | macOS sound name or absolute `.aiff` path |
|
|
176
|
-
| `PI_TERMINAL_BELL_NOTIFY=0` | Disable OS notification only |
|
|
177
|
-
| `PI_TERMINAL_BELL_NOTIFY=1` | Force OS notification even outside UI mode |
|
|
178
|
-
| `PI_TERMINAL_BELL_NOTIFY_TITLE=Pi` | Notification title |
|
|
179
|
-
| `PI_TERMINAL_BELL_NOTIFY_MESSAGE="Session stopped"` | Notification body |
|
|
180
|
-
|
|
181
|
-
macOS clickable notifications require `terminal-notifier`:
|
|
182
|
-
|
|
183
|
-
```bash
|
|
184
|
-
brew install terminal-notifier
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
At extension startup, the module resolves the app to activate on click from `PI_TERMINAL_BELL_NOTIFY_ACTIVATE`, `__CFBundleIdentifier`, or `TERM_PROGRAM` (Zed, iTerm2, Terminal, WezTerm, Warp, Ghostty, Kitty, Alacritty, VS Code). The resolved bundle id is passed to `terminal-notifier -activate` and to an explicit `open -b <bundleId>` click action.
|
|
188
|
-
|
|
189
|
-
macOS-specific notification options:
|
|
190
|
-
|
|
191
|
-
| Variable | Effect |
|
|
192
|
-
| --- | --- |
|
|
193
|
-
| `PI_TERMINAL_BELL_NOTIFY_ACTIVATE=dev.zed.Zed` | Override click activation bundle id |
|
|
194
|
-
| `PI_TERMINAL_BELL_NOTIFY_ACTIVATE=0` | Disable click activation |
|
|
195
|
-
| `PI_TERMINAL_BELL_NOTIFIER=/path/to/terminal-notifier` | Use a custom notifier binary |
|
|
196
|
-
| `PI_TERMINAL_BELL_NOTIFY_SENDER=1` | Also pass `-sender <bundleId>` (can break click handling on some macOS versions) |
|
|
197
|
-
| `PI_TERMINAL_BELL_NOTIFY_OSASCRIPT=1` | Use the `osascript` fallback when `terminal-notifier` is missing; clicking these notifications can open Script Editor |
|
|
198
|
-
|
|
199
154
|
## Layout
|
|
200
155
|
|
|
201
156
|
```text
|
|
@@ -206,7 +161,6 @@ pi-tools-suite/
|
|
|
206
161
|
index.ts
|
|
207
162
|
ast-grep/
|
|
208
163
|
async-subagents/
|
|
209
|
-
terminal-bell/
|
|
210
164
|
lsp/
|
|
211
165
|
repo-discovery/
|
|
212
166
|
antigravity-auth/
|
|
@@ -5,7 +5,6 @@ export const DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC = `{
|
|
|
5
5
|
"disabledModules": [
|
|
6
6
|
// "ast-grep",
|
|
7
7
|
// "async-subagents",
|
|
8
|
-
// "terminal-bell",
|
|
9
8
|
// "lsp",
|
|
10
9
|
// "repo-discovery",
|
|
11
10
|
// "antigravity-auth",
|
|
@@ -16,7 +15,7 @@ export const DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC = `{
|
|
|
16
15
|
// "dcp"
|
|
17
16
|
],
|
|
18
17
|
|
|
19
|
-
//
|
|
18
|
+
// Renderer bundled terminal-bell notification settings.
|
|
20
19
|
"terminalBell": {
|
|
21
20
|
// Toggle terminal-bell attention signals across supported OSes:
|
|
22
21
|
// terminal BEL, macOS notification sound, and system notifications
|
|
@@ -12,7 +12,6 @@ type ExtensionModule = {
|
|
|
12
12
|
const MODULES: Array<{ name: string; load: () => Promise<ExtensionModule> }> = [
|
|
13
13
|
{ name: "ast-grep", load: () => import("./ast-grep/index") },
|
|
14
14
|
{ name: "async-subagents", load: () => import("./async-subagents/index") },
|
|
15
|
-
{ name: "terminal-bell", load: () => import("./terminal-bell/index") },
|
|
16
15
|
{ name: "lsp", load: () => import("./lsp/index") },
|
|
17
16
|
{ name: "repo-discovery", load: () => import("./repo-discovery/index") },
|
|
18
17
|
{ name: "antigravity-auth", load: () => import("./antigravity-auth/index") },
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { parse as parseJsonc } from "jsonc-parser";
|
|
5
|
+
import { getPiToolsSuiteUserConfigPath } from "../../config";
|
|
4
6
|
import { findUp } from "./paths";
|
|
5
7
|
import { askProjectConfigTrust, sha256 } from "./trust";
|
|
6
8
|
import type { ConfigLayer, LoadedConfig, LspConfigFile, LspServerConfig, MatchableConfig } from "./types";
|
|
7
9
|
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
+
function getPiConfigDir(): string | undefined {
|
|
11
|
+
const configured = process.env.PI_CONFIG_DIR;
|
|
12
|
+
return configured && configured.trim() !== "" ? configured : undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function findProjectSuiteConfig(startDir: string): string | undefined {
|
|
16
|
+
return findUp(startDir, path.join(".pi", "pi-tools-suite.jsonc"));
|
|
10
17
|
}
|
|
11
18
|
|
|
12
19
|
function asObject(value: unknown): Record<string, unknown> | undefined {
|
|
@@ -78,9 +85,16 @@ function parseLspItems(parsed: unknown): LspServerConfig[] {
|
|
|
78
85
|
return out;
|
|
79
86
|
}
|
|
80
87
|
|
|
81
|
-
|
|
88
|
+
function extractLspConfig(parsed: unknown): unknown {
|
|
89
|
+
const root = asObject(parsed);
|
|
90
|
+
if (!root) return undefined;
|
|
91
|
+
return root.lsp;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function readJsoncLayer<TItem extends MatchableConfig>(options: {
|
|
82
95
|
scope: "global" | "project";
|
|
83
96
|
filePath: string;
|
|
97
|
+
selectConfig?: (parsed: unknown) => unknown;
|
|
84
98
|
parseItems: (parsed: unknown) => TItem[];
|
|
85
99
|
}): Promise<ConfigLayer<TItem> | undefined> {
|
|
86
100
|
let raw: string;
|
|
@@ -91,14 +105,19 @@ async function readJsonLayer<TItem extends MatchableConfig>(options: {
|
|
|
91
105
|
throw error;
|
|
92
106
|
}
|
|
93
107
|
|
|
94
|
-
const parsed =
|
|
108
|
+
const parsed = parseJsonc(raw) as unknown;
|
|
109
|
+
const selected = options.selectConfig ? options.selectConfig(parsed) : parsed;
|
|
110
|
+
if (selected === undefined) return undefined;
|
|
111
|
+
const items = options.parseItems(selected);
|
|
112
|
+
if (items.length === 0) return undefined;
|
|
113
|
+
|
|
95
114
|
return {
|
|
96
115
|
scope: options.scope,
|
|
97
116
|
path: options.filePath,
|
|
98
117
|
dir: path.dirname(options.filePath),
|
|
99
118
|
raw,
|
|
100
119
|
hash: sha256(raw),
|
|
101
|
-
items
|
|
120
|
+
items,
|
|
102
121
|
};
|
|
103
122
|
}
|
|
104
123
|
|
|
@@ -123,19 +142,25 @@ function binariesForLsp(items: LspServerConfig[]): string[] {
|
|
|
123
142
|
export async function loadLspConfig(ctx: ExtensionContext): Promise<LoadedConfig<LspServerConfig>> {
|
|
124
143
|
const warnings: string[] = [];
|
|
125
144
|
const layers: ConfigLayer<LspServerConfig>[] = [];
|
|
126
|
-
const
|
|
145
|
+
const piConfigDir = getPiConfigDir();
|
|
146
|
+
const globalPaths = [
|
|
147
|
+
getPiToolsSuiteUserConfigPath(process.env.HOME),
|
|
148
|
+
piConfigDir ? path.join(piConfigDir, "pi-tools-suite.jsonc") : undefined,
|
|
149
|
+
].filter((item): item is string => typeof item === "string");
|
|
127
150
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
151
|
+
for (const globalPath of globalPaths) {
|
|
152
|
+
try {
|
|
153
|
+
const globalLayer = await readJsoncLayer({ scope: "global", filePath: globalPath, selectConfig: extractLspConfig, parseItems: parseLspItems });
|
|
154
|
+
if (globalLayer) layers.push(globalLayer);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
warnings.push(`Failed to load global lsp config ${globalPath}: ${(error as Error).message}`);
|
|
157
|
+
}
|
|
133
158
|
}
|
|
134
159
|
|
|
135
|
-
const projectPath =
|
|
160
|
+
const projectPath = findProjectSuiteConfig(ctx.cwd);
|
|
136
161
|
if (projectPath) {
|
|
137
162
|
try {
|
|
138
|
-
const projectLayer = await
|
|
163
|
+
const projectLayer = await readJsoncLayer({ scope: "project", filePath: projectPath, selectConfig: extractLspConfig, parseItems: parseLspItems });
|
|
139
164
|
if (projectLayer) {
|
|
140
165
|
const decision = await askProjectConfigTrust({
|
|
141
166
|
ctx,
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
4
|
import type { CommandConfig, PathPlaceholders, ResolvedCommand } from "./types";
|
|
5
5
|
import { applyTemplate, applyTemplateArray, applyTemplateRecord } from "./template";
|
|
6
|
+
import { matchesGlob } from "./glob";
|
|
6
7
|
|
|
7
8
|
export function expandHome(input: string): string {
|
|
8
9
|
if (input === "~") return process.env.HOME ?? input;
|
|
@@ -26,7 +27,16 @@ export function isSubPathOrSame(parent: string, child: string): boolean {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export function markerExists(candidateDir: string, marker: string): boolean {
|
|
29
|
-
return fs.existsSync(path.resolve(candidateDir, marker));
|
|
30
|
+
if (!/[?*[]/.test(marker)) return fs.existsSync(path.resolve(candidateDir, marker));
|
|
31
|
+
|
|
32
|
+
const markerDir = path.dirname(marker);
|
|
33
|
+
const markerPattern = path.basename(marker);
|
|
34
|
+
const absoluteMarkerDir = path.resolve(candidateDir, markerDir === "." ? "" : markerDir);
|
|
35
|
+
try {
|
|
36
|
+
return fs.readdirSync(absoluteMarkerDir).some((entry) => matchesGlob(markerPattern, entry));
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
export function findProjectRoot(filePath: string, rootMarkers: string[] | undefined, fallbackRoot: string): string | undefined {
|
|
@@ -18,12 +18,17 @@ export function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
18
18
|
});
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
21
|
+
export async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string, signal?: AbortSignal): Promise<T> {
|
|
22
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
22
23
|
let timeout: NodeJS.Timeout | undefined;
|
|
24
|
+
let abort: (() => void) | undefined;
|
|
23
25
|
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
24
26
|
timeout = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
27
|
+
abort = () => reject(new Error("aborted"));
|
|
28
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
25
29
|
});
|
|
26
30
|
return Promise.race([promise, timeoutPromise]).finally(() => {
|
|
27
31
|
if (timeout) clearTimeout(timeout);
|
|
32
|
+
if (abort) signal?.removeEventListener("abort", abort);
|
|
28
33
|
});
|
|
29
34
|
}
|
|
@@ -10,6 +10,7 @@ function canWriteToChild(child: ChildProcessWithoutNullStreams): boolean {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function killChild(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): boolean {
|
|
13
|
+
let signaled = false;
|
|
13
14
|
try {
|
|
14
15
|
if (process.platform === "win32" && child.pid) {
|
|
15
16
|
const result = spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
|
@@ -19,9 +20,22 @@ export function killChild(child: ChildProcessWithoutNullStreams, signal: NodeJS.
|
|
|
19
20
|
});
|
|
20
21
|
if (!result.error && result.status === 0) return true;
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
+
|
|
24
|
+
if (child.pid) {
|
|
25
|
+
try {
|
|
26
|
+
// LSP clients are spawned in their own process group on POSIX. Kill the
|
|
27
|
+
// whole group so wrapper scripts also lose children such as Godot and nc.
|
|
28
|
+
process.kill(-child.pid, signal);
|
|
29
|
+
signaled = true;
|
|
30
|
+
} catch {
|
|
31
|
+
// Fall back to the direct child below. The process may not be detached
|
|
32
|
+
// or may have already exited.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return child.kill(signal) || signaled;
|
|
23
37
|
} catch {
|
|
24
|
-
return
|
|
38
|
+
return signaled;
|
|
25
39
|
}
|
|
26
40
|
}
|
|
27
41
|
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
2
3
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
3
4
|
import type { MessageConnection } from "vscode-jsonrpc";
|
|
4
5
|
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node";
|
|
5
6
|
import {
|
|
6
7
|
DefinitionRequest,
|
|
8
|
+
DiagnosticRefreshRequest,
|
|
7
9
|
DidChangeConfigurationNotification,
|
|
8
10
|
DidChangeTextDocumentNotification,
|
|
9
11
|
DidOpenTextDocumentNotification,
|
|
10
12
|
DidSaveTextDocumentNotification,
|
|
13
|
+
DocumentDiagnosticRequest,
|
|
11
14
|
DocumentSymbolRequest,
|
|
12
15
|
ExecuteCommandRequest,
|
|
13
16
|
HoverRequest,
|
|
@@ -16,6 +19,7 @@ import {
|
|
|
16
19
|
PublishDiagnosticsNotification,
|
|
17
20
|
ReferencesRequest,
|
|
18
21
|
type Diagnostic,
|
|
22
|
+
type DocumentDiagnosticReport,
|
|
19
23
|
type InitializeResult,
|
|
20
24
|
type ServerCapabilities,
|
|
21
25
|
} from "vscode-languageserver-protocol";
|
|
@@ -24,11 +28,38 @@ import { bestEffortWriteJsonRpc, isChildRunning, killChild, terminateChild } fro
|
|
|
24
28
|
import { DEFAULT_STARTUP_TIMEOUT_MS, REQUEST_TIMEOUT_MS } from "./constants";
|
|
25
29
|
import { DocumentStore } from "./documents";
|
|
26
30
|
import type { DiagnosticsStore } from "./diagnostics-store";
|
|
27
|
-
import { filePathToUri } from "./_shared/paths";
|
|
31
|
+
import { filePathToUri, uriToFilePath } from "./_shared/paths";
|
|
28
32
|
import { isExecutableAvailable } from "./_shared/runner";
|
|
29
33
|
import type { LspServerConfig, ResolvedCommand } from "./_shared/types";
|
|
30
34
|
import { supportsSave } from "./lsp-utils";
|
|
31
35
|
import type { OpenDocument } from "./types";
|
|
36
|
+
|
|
37
|
+
interface MarkdownToken {
|
|
38
|
+
type: string;
|
|
39
|
+
markup: string;
|
|
40
|
+
content: string;
|
|
41
|
+
map: number[] | null;
|
|
42
|
+
children: MarkdownToken[] | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function markdownTextToken(content: string): MarkdownToken {
|
|
46
|
+
return { type: "text", markup: "", content, map: null, children: null };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseMarkdownTokens(text: string): MarkdownToken[] {
|
|
50
|
+
const tokens: MarkdownToken[] = [];
|
|
51
|
+
const lines = text.split(/\r?\n/);
|
|
52
|
+
for (let lineNumber = 0; lineNumber < lines.length; lineNumber += 1) {
|
|
53
|
+
const line = lines[lineNumber];
|
|
54
|
+
const heading = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
55
|
+
if (!heading) continue;
|
|
56
|
+
const [, markup, content] = heading;
|
|
57
|
+
tokens.push({ type: "heading_open", markup, content: "", map: [lineNumber, lineNumber + 1], children: null });
|
|
58
|
+
tokens.push({ type: "inline", markup: "", content, map: [lineNumber, lineNumber + 1], children: [markdownTextToken(content.trim())] });
|
|
59
|
+
tokens.push({ type: "heading_close", markup, content: "", map: null, children: null });
|
|
60
|
+
}
|
|
61
|
+
return tokens;
|
|
62
|
+
}
|
|
32
63
|
import { tsserverDiagnosticToLsp, tsserverDiagnosticsFromResponse } from "./tsserver";
|
|
33
64
|
|
|
34
65
|
export class LspClient {
|
|
@@ -40,6 +71,8 @@ export class LspClient {
|
|
|
40
71
|
private initialized = false;
|
|
41
72
|
private unavailableReason: string | undefined;
|
|
42
73
|
private stderrTail = "";
|
|
74
|
+
private readonly dynamicDiagnosticProviders = new Map<string, string | undefined>();
|
|
75
|
+
private diagnosticProviderWaiters: Array<() => void> = [];
|
|
43
76
|
|
|
44
77
|
constructor(
|
|
45
78
|
private readonly server: LspServerConfig,
|
|
@@ -76,6 +109,7 @@ export class LspClient {
|
|
|
76
109
|
const child = spawn(this.command.bin, this.command.args, {
|
|
77
110
|
cwd: this.command.cwd,
|
|
78
111
|
env: this.command.env ? { ...process.env, ...this.command.env } : process.env,
|
|
112
|
+
detached: process.platform !== "win32",
|
|
79
113
|
shell: false,
|
|
80
114
|
stdio: ["pipe", "pipe", "pipe"],
|
|
81
115
|
});
|
|
@@ -113,6 +147,7 @@ export class LspClient {
|
|
|
113
147
|
Promise.race([connection.sendRequest(InitializeRequest.method, this.initializeParams()), startupFailure]),
|
|
114
148
|
this.server.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS,
|
|
115
149
|
`${this.server.id} initialize`,
|
|
150
|
+
signal,
|
|
116
151
|
)) as InitializeResult;
|
|
117
152
|
this.capabilities = initializeResult.capabilities;
|
|
118
153
|
failStartup = undefined;
|
|
@@ -136,10 +171,12 @@ export class LspClient {
|
|
|
136
171
|
window: { workDoneProgress: true },
|
|
137
172
|
workspace: {
|
|
138
173
|
configuration: true,
|
|
174
|
+
diagnostics: { refreshSupport: true },
|
|
139
175
|
workspaceFolders: true,
|
|
140
176
|
didChangeWatchedFiles: { dynamicRegistration: true },
|
|
141
177
|
},
|
|
142
178
|
textDocument: {
|
|
179
|
+
diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true },
|
|
143
180
|
synchronization: {
|
|
144
181
|
didOpen: true,
|
|
145
182
|
didChange: true,
|
|
@@ -178,8 +215,67 @@ export class LspClient {
|
|
|
178
215
|
return items.map(() => this.server.settings ?? {});
|
|
179
216
|
});
|
|
180
217
|
anyConnection.onRequest("workspace/workspaceFolders", () => [{ name: path.basename(this.root), uri: filePathToUri(this.root) }]);
|
|
181
|
-
anyConnection.onRequest("
|
|
182
|
-
|
|
218
|
+
anyConnection.onRequest("markdown/parse", async (params: unknown) => {
|
|
219
|
+
const request = params as { uri?: unknown; text?: unknown } | undefined;
|
|
220
|
+
const text = typeof request?.text === "string"
|
|
221
|
+
? request.text
|
|
222
|
+
: typeof request?.uri === "string"
|
|
223
|
+
? this.documents.get(uriToFilePath(request.uri))?.text ?? await fs.readFile(uriToFilePath(request.uri), "utf8")
|
|
224
|
+
: "";
|
|
225
|
+
return parseMarkdownTokens(text);
|
|
226
|
+
});
|
|
227
|
+
anyConnection.onRequest("markdown/fs/readFile", async (params: unknown) => {
|
|
228
|
+
const uri = (params as { uri?: unknown } | undefined)?.uri;
|
|
229
|
+
if (typeof uri !== "string") return [];
|
|
230
|
+
return [...await fs.readFile(uriToFilePath(uri))];
|
|
231
|
+
});
|
|
232
|
+
anyConnection.onRequest("markdown/fs/stat", async (params: unknown) => {
|
|
233
|
+
const uri = (params as { uri?: unknown } | undefined)?.uri;
|
|
234
|
+
if (typeof uri !== "string") return undefined;
|
|
235
|
+
try {
|
|
236
|
+
const stat = await fs.stat(uriToFilePath(uri));
|
|
237
|
+
return { isDirectory: stat.isDirectory() };
|
|
238
|
+
} catch {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
anyConnection.onRequest("markdown/fs/readDirectory", async (params: unknown) => {
|
|
243
|
+
const uri = (params as { uri?: unknown } | undefined)?.uri;
|
|
244
|
+
if (typeof uri !== "string") return [];
|
|
245
|
+
try {
|
|
246
|
+
const entries = await fs.readdir(uriToFilePath(uri), { withFileTypes: true });
|
|
247
|
+
return entries.map((entry) => [entry.name, { isDirectory: entry.isDirectory() }]);
|
|
248
|
+
} catch {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
anyConnection.onRequest("markdown/fs/watcher/create", () => null);
|
|
253
|
+
anyConnection.onRequest("markdown/fs/watcher/delete", () => null);
|
|
254
|
+
anyConnection.onRequest("markdown/findMarkdownFilesInWorkspace", () => []);
|
|
255
|
+
anyConnection.onRequest("client/registerCapability", (params: unknown) => {
|
|
256
|
+
const registrations = (params as { registrations?: unknown[] } | undefined)?.registrations;
|
|
257
|
+
if (!Array.isArray(registrations)) return null;
|
|
258
|
+
|
|
259
|
+
for (const registration of registrations) {
|
|
260
|
+
const item = registration as { id?: unknown; method?: unknown; registerOptions?: unknown };
|
|
261
|
+
if (typeof item.id !== "string" || item.method !== DocumentDiagnosticRequest.method) continue;
|
|
262
|
+
const options = item.registerOptions as { identifier?: unknown } | undefined;
|
|
263
|
+
this.dynamicDiagnosticProviders.set(item.id, typeof options?.identifier === "string" ? options.identifier : undefined);
|
|
264
|
+
}
|
|
265
|
+
this.resolveDiagnosticProviderWaiters();
|
|
266
|
+
return null;
|
|
267
|
+
});
|
|
268
|
+
anyConnection.onRequest("client/unregisterCapability", (params: unknown) => {
|
|
269
|
+
const unregisterations = (params as { unregisterations?: unknown[] } | undefined)?.unregisterations;
|
|
270
|
+
if (!Array.isArray(unregisterations)) return null;
|
|
271
|
+
|
|
272
|
+
for (const registration of unregisterations) {
|
|
273
|
+
const item = registration as { id?: unknown; method?: unknown };
|
|
274
|
+
if (typeof item.id === "string" && item.method === DocumentDiagnosticRequest.method) this.dynamicDiagnosticProviders.delete(item.id);
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
});
|
|
278
|
+
anyConnection.onRequest(DiagnosticRefreshRequest.method, () => null);
|
|
183
279
|
anyConnection.onRequest("window/workDoneProgress/create", () => null);
|
|
184
280
|
anyConnection.onNotification("window/logMessage", () => undefined);
|
|
185
281
|
anyConnection.onNotification("telemetry/event", () => undefined);
|
|
@@ -226,7 +322,50 @@ export class LspClient {
|
|
|
226
322
|
return Array.isArray(commands) && commands.includes("typescript.tsserverRequest");
|
|
227
323
|
}
|
|
228
324
|
|
|
229
|
-
|
|
325
|
+
private supportsPullDiagnostics(): boolean {
|
|
326
|
+
return !!this.capabilities?.diagnosticProvider || this.dynamicDiagnosticProviders.size > 0;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private resolveDiagnosticProviderWaiters(): void {
|
|
330
|
+
const waiters = this.diagnosticProviderWaiters;
|
|
331
|
+
this.diagnosticProviderWaiters = [];
|
|
332
|
+
for (const waiter of waiters) waiter();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private async waitForPullDiagnosticsSupport(timeoutMs: number, signal?: AbortSignal): Promise<void> {
|
|
336
|
+
if (this.supportsPullDiagnostics() || timeoutMs <= 0) return;
|
|
337
|
+
await withTimeout(new Promise<void>((resolve) => {
|
|
338
|
+
this.diagnosticProviderWaiters.push(resolve);
|
|
339
|
+
}), timeoutMs, `${this.server.id} diagnostic registration`, signal).catch(() => undefined);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private diagnosticProviderIdentifiers(): Array<string | undefined> {
|
|
343
|
+
const identifiers: Array<string | undefined> = [];
|
|
344
|
+
const provider = this.capabilities?.diagnosticProvider;
|
|
345
|
+
if (provider) {
|
|
346
|
+
identifiers.push(
|
|
347
|
+
typeof provider === "object" && "identifier" in provider && typeof provider.identifier === "string"
|
|
348
|
+
? provider.identifier
|
|
349
|
+
: undefined,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
for (const identifier of this.dynamicDiagnosticProviders.values()) identifiers.push(identifier);
|
|
353
|
+
|
|
354
|
+
const seen = new Set<string>();
|
|
355
|
+
return identifiers.filter((identifier) => {
|
|
356
|
+
const key = identifier ?? "";
|
|
357
|
+
if (seen.has(key)) return false;
|
|
358
|
+
seen.add(key);
|
|
359
|
+
return true;
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private diagnosticsFromReport(report: DocumentDiagnosticReport | null | undefined): Diagnostic[] | undefined {
|
|
364
|
+
if (!report || report.kind !== "full") return undefined;
|
|
365
|
+
return report.items;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async tsserverDiagnostics(file: string, text: string, timeoutMs: number, signal?: AbortSignal): Promise<Diagnostic[] | undefined> {
|
|
230
369
|
const connection = this.connection;
|
|
231
370
|
if (!connection || !this.supportsTsserverDiagnostics()) return undefined;
|
|
232
371
|
|
|
@@ -252,11 +391,51 @@ export class LspClient {
|
|
|
252
391
|
}),
|
|
253
392
|
timeoutMs,
|
|
254
393
|
`${this.server.id} ${request.command}`,
|
|
394
|
+
signal,
|
|
255
395
|
)));
|
|
256
396
|
|
|
257
397
|
return responses.flatMap((response) => tsserverDiagnosticsFromResponse(response).map((diagnostic) => tsserverDiagnosticToLsp(diagnostic, text)));
|
|
258
398
|
}
|
|
259
399
|
|
|
400
|
+
async pullDiagnostics(file: string, timeoutMs: number, signal?: AbortSignal): Promise<Diagnostic[] | undefined> {
|
|
401
|
+
const connection = this.connection;
|
|
402
|
+
if (!connection) return undefined;
|
|
403
|
+
if (!this.supportsPullDiagnostics()) {
|
|
404
|
+
await this.waitForPullDiagnosticsSupport(this.server.id === "csharp" ? Math.min(timeoutMs, 5_000) : 250, signal);
|
|
405
|
+
}
|
|
406
|
+
if (!this.supportsPullDiagnostics()) return undefined;
|
|
407
|
+
const identifiers = this.diagnosticProviderIdentifiers();
|
|
408
|
+
if (identifiers.length === 0) return undefined;
|
|
409
|
+
|
|
410
|
+
const uri = filePathToUri(file);
|
|
411
|
+
const settled = await Promise.allSettled(identifiers.map(async (identifier) => {
|
|
412
|
+
const report = (await withTimeout(
|
|
413
|
+
connection.sendRequest(DocumentDiagnosticRequest.method, {
|
|
414
|
+
textDocument: { uri },
|
|
415
|
+
identifier,
|
|
416
|
+
}),
|
|
417
|
+
timeoutMs,
|
|
418
|
+
`${this.server.id} textDocument/diagnostic${identifier ? ` (${identifier})` : ""}`,
|
|
419
|
+
signal,
|
|
420
|
+
)) as DocumentDiagnosticReport | null;
|
|
421
|
+
return this.diagnosticsFromReport(report) ?? [];
|
|
422
|
+
}));
|
|
423
|
+
|
|
424
|
+
const fulfilled = settled.filter((result): result is PromiseFulfilledResult<Diagnostic[]> => result.status === "fulfilled");
|
|
425
|
+
if (fulfilled.length === 0) {
|
|
426
|
+
const firstError = settled.find((result): result is PromiseRejectedResult => result.status === "rejected")?.reason;
|
|
427
|
+
throw firstError instanceof Error ? firstError : new Error(String(firstError ?? "pull diagnostics failed"));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const seen = new Set<string>();
|
|
431
|
+
return fulfilled.flatMap((result) => result.value).filter((diagnostic) => {
|
|
432
|
+
const key = JSON.stringify([diagnostic.range, diagnostic.severity, diagnostic.source, diagnostic.code, diagnostic.message]);
|
|
433
|
+
if (seen.has(key)) return false;
|
|
434
|
+
seen.add(key);
|
|
435
|
+
return true;
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
260
439
|
async hover(file: string, line: number, character: number): Promise<unknown> {
|
|
261
440
|
if (!this.connection) throw new Error(`${this.server.id}: LSP connection unavailable`);
|
|
262
441
|
return withTimeout(
|
|
@@ -9,6 +9,7 @@ import { filePathToUri, findProjectRoot, normalizeRelativePath, resolveCommand,
|
|
|
9
9
|
import { formatLspDiagnostics, formatWarnings, joinSections } from "./_shared/output";
|
|
10
10
|
import type { LspServerConfig, StoredDiagnostics } from "./_shared/types";
|
|
11
11
|
import { clientKey, couldMatchBeforeRoot, fileSizeAllowed, languageIdForFile, readTextFile } from "./lsp-utils";
|
|
12
|
+
import { localMarkdownDiagnostics } from "./markdown-diagnostics";
|
|
12
13
|
import type { MatchedServer } from "./types";
|
|
13
14
|
|
|
14
15
|
function isFreshDiagnosticsEntry(entry: StoredDiagnostics | undefined, since: number, version: number | undefined): entry is StoredDiagnostics {
|
|
@@ -17,6 +18,27 @@ function isFreshDiagnosticsEntry(entry: StoredDiagnostics | undefined, since: nu
|
|
|
17
18
|
&& (entry.version === undefined || version === undefined || entry.version >= version);
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
function diagnosticsWithLocalFallback(serverId: string, file: string, text: string, diagnostics: StoredDiagnostics["diagnostics"]): StoredDiagnostics["diagnostics"] {
|
|
22
|
+
if (serverId !== "markdown") return diagnostics;
|
|
23
|
+
const hasLanguageServerLinkDiagnostics = diagnostics.some((diagnostic) => typeof diagnostic.code === "string" && diagnostic.code.startsWith("link."));
|
|
24
|
+
const localDiagnostics = localMarkdownDiagnostics(file, text).filter((diagnostic) => {
|
|
25
|
+
if (!hasLanguageServerLinkDiagnostics) return true;
|
|
26
|
+
return !(typeof diagnostic.code === "string" && diagnostic.code.startsWith("link."));
|
|
27
|
+
});
|
|
28
|
+
if (localDiagnostics.length === 0) return diagnostics;
|
|
29
|
+
|
|
30
|
+
const seen = new Set(diagnostics.map((diagnostic) => JSON.stringify([diagnostic.range, diagnostic.severity, diagnostic.source, diagnostic.code, diagnostic.message])));
|
|
31
|
+
return [
|
|
32
|
+
...diagnostics,
|
|
33
|
+
...localDiagnostics.filter((diagnostic) => {
|
|
34
|
+
const key = JSON.stringify([diagnostic.range, diagnostic.severity, diagnostic.source, diagnostic.code, diagnostic.message]);
|
|
35
|
+
if (seen.has(key)) return false;
|
|
36
|
+
seen.add(key);
|
|
37
|
+
return true;
|
|
38
|
+
}),
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
20
42
|
export class LspManager {
|
|
21
43
|
private readonly diagnostics = new DiagnosticsStore();
|
|
22
44
|
private readonly clients = new Map<string, LspClient>();
|
|
@@ -115,16 +137,30 @@ export class LspManager {
|
|
|
115
137
|
// diagnostics don't degrade into a misleading publishDiagnostics timeout.
|
|
116
138
|
let tsserverFallbackError: string | undefined;
|
|
117
139
|
try {
|
|
118
|
-
const tsserverDiagnostics = await client.tsserverDiagnostics(file, text, diagnosticsWaitMs);
|
|
140
|
+
const tsserverDiagnostics = await client.tsserverDiagnostics(file, text, diagnosticsWaitMs, ctx.signal);
|
|
119
141
|
if (tsserverDiagnostics !== undefined) {
|
|
120
|
-
|
|
121
|
-
|
|
142
|
+
const diagnostics = diagnosticsWithLocalFallback(match.server.id, file, text, tsserverDiagnostics);
|
|
143
|
+
this.diagnostics.set(match.server.id, match.root, filePathToUri(file), diagnostics, doc.version);
|
|
144
|
+
lines.push(formatLspDiagnostics(match.server.id, file, diagnostics, match.root));
|
|
122
145
|
continue;
|
|
123
146
|
}
|
|
124
147
|
} catch (error) {
|
|
125
148
|
tsserverFallbackError = (error as Error).message;
|
|
126
149
|
}
|
|
127
150
|
|
|
151
|
+
let pullDiagnosticsError: string | undefined;
|
|
152
|
+
try {
|
|
153
|
+
const pulledDiagnostics = await client.pullDiagnostics(file, diagnosticsWaitMs, ctx.signal);
|
|
154
|
+
if (pulledDiagnostics !== undefined) {
|
|
155
|
+
const diagnostics = diagnosticsWithLocalFallback(match.server.id, file, text, pulledDiagnostics);
|
|
156
|
+
this.diagnostics.set(match.server.id, match.root, filePathToUri(file), diagnostics, doc.version);
|
|
157
|
+
lines.push(formatLspDiagnostics(match.server.id, file, diagnostics, match.root));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
} catch (error) {
|
|
161
|
+
pullDiagnosticsError = (error as Error).message;
|
|
162
|
+
}
|
|
163
|
+
|
|
128
164
|
const entry = await this.diagnostics.waitForFile(
|
|
129
165
|
match.server.id,
|
|
130
166
|
match.root,
|
|
@@ -136,10 +172,13 @@ export class LspManager {
|
|
|
136
172
|
);
|
|
137
173
|
if (!isFreshDiagnosticsEntry(entry, startedAt, doc.version)) {
|
|
138
174
|
const fallbackSuffix = tsserverFallbackError ? `; tsserver fallback failed: ${tsserverFallbackError}` : "";
|
|
139
|
-
|
|
175
|
+
const pullSuffix = pullDiagnosticsError ? `; pull diagnostics failed: ${pullDiagnosticsError}` : "";
|
|
176
|
+
lines.push(`⚠️ ${match.server.id}: timed out after ${diagnosticsWaitMs}ms waiting for fresh diagnostics for ${match.relFile}${fallbackSuffix}${pullSuffix}`);
|
|
140
177
|
continue;
|
|
141
178
|
}
|
|
142
|
-
|
|
179
|
+
const diagnostics = diagnosticsWithLocalFallback(match.server.id, file, text, entry.diagnostics);
|
|
180
|
+
if (diagnostics !== entry.diagnostics) this.diagnostics.set(match.server.id, match.root, filePathToUri(file), diagnostics, doc.version);
|
|
181
|
+
lines.push(formatLspDiagnostics(match.server.id, file, diagnostics, match.root));
|
|
143
182
|
} catch (error) {
|
|
144
183
|
lines.push(`⚠️ ${match.server.id}: ${(error as Error).message}`);
|
|
145
184
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import type { Diagnostic } from "vscode-languageserver-protocol";
|
|
4
|
+
|
|
5
|
+
interface LineOffsets {
|
|
6
|
+
readonly text: string;
|
|
7
|
+
readonly starts: number[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function lineOffsets(text: string): LineOffsets {
|
|
11
|
+
const starts = [0];
|
|
12
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
13
|
+
if (text[index] === "\n") starts.push(index + 1);
|
|
14
|
+
}
|
|
15
|
+
return { text, starts };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function positionAt(offsets: LineOffsets, offset: number): { line: number; character: number } {
|
|
19
|
+
let low = 0;
|
|
20
|
+
let high = offsets.starts.length - 1;
|
|
21
|
+
while (low <= high) {
|
|
22
|
+
const mid = Math.floor((low + high) / 2);
|
|
23
|
+
if (offsets.starts[mid] <= offset) low = mid + 1;
|
|
24
|
+
else high = mid - 1;
|
|
25
|
+
}
|
|
26
|
+
const line = Math.max(0, high);
|
|
27
|
+
return { line, character: Math.max(0, offset - offsets.starts[line]) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function diagnostic(offsets: LineOffsets, start: number, end: number, message: string, code: string, severity: Diagnostic["severity"] = 1): Diagnostic {
|
|
31
|
+
return {
|
|
32
|
+
severity,
|
|
33
|
+
source: "pi-markdown",
|
|
34
|
+
code,
|
|
35
|
+
message,
|
|
36
|
+
range: {
|
|
37
|
+
start: positionAt(offsets, start),
|
|
38
|
+
end: positionAt(offsets, Math.max(end, start + 1)),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function addMarkdownLinkDiagnostics(file: string, text: string, offsets: LineOffsets, out: Diagnostic[]): void {
|
|
44
|
+
const definitions = new Map<string, Array<{ start: number; end: number }>>();
|
|
45
|
+
const usedReferences = new Set<string>();
|
|
46
|
+
|
|
47
|
+
for (const match of text.matchAll(/^\s{0,3}\[([^\]\r\n]+)\]:\s*(\S+)/gm)) {
|
|
48
|
+
const ref = match[1].trim().toLocaleLowerCase();
|
|
49
|
+
const start = (match.index ?? 0) + match[0].indexOf(match[1]);
|
|
50
|
+
const end = start + match[1].length;
|
|
51
|
+
const existing = definitions.get(ref) ?? [];
|
|
52
|
+
existing.push({ start, end });
|
|
53
|
+
definitions.set(ref, existing);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const [ref, locations] of definitions) {
|
|
57
|
+
if (locations.length <= 1) continue;
|
|
58
|
+
for (const location of locations) {
|
|
59
|
+
out.push(diagnostic(offsets, location.start, location.end, `Duplicate link definition: '${ref}'`, "link.duplicate-definition", 2));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const match of text.matchAll(/(?<!!)(?:\[[^\]\r\n]+\]\[([^\]\r\n]*)\]|\[([^\]\r\n]+)\]\[\])/g)) {
|
|
64
|
+
const full = match[0];
|
|
65
|
+
const explicit = match[1];
|
|
66
|
+
const collapsed = match[2];
|
|
67
|
+
const ref = (explicit === "" ? collapsed : explicit)?.trim();
|
|
68
|
+
if (!ref) continue;
|
|
69
|
+
const normalized = ref.toLocaleLowerCase();
|
|
70
|
+
usedReferences.add(normalized);
|
|
71
|
+
if (definitions.has(normalized)) continue;
|
|
72
|
+
const start = (match.index ?? 0) + full.lastIndexOf(ref);
|
|
73
|
+
out.push(diagnostic(offsets, start, start + ref.length, `No link definition found: '${ref}'`, "link.no-such-reference"));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const [ref, locations] of definitions) {
|
|
77
|
+
if (usedReferences.has(ref)) continue;
|
|
78
|
+
for (const location of locations) {
|
|
79
|
+
out.push(diagnostic(offsets, location.start, location.end, "Link definition is unused", "link.unused-definition", 4));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const fileLinkPattern = /(?<!!)\[[^\]\r\n]+\]\(([^)\s]+)(?:\s+[^)]*)?\)/g;
|
|
84
|
+
for (const match of text.matchAll(fileLinkPattern)) {
|
|
85
|
+
const href = match[1];
|
|
86
|
+
if (!href || /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("#")) continue;
|
|
87
|
+
const [targetPath] = href.split("#", 1);
|
|
88
|
+
if (!targetPath || targetPath.startsWith("mailto:")) continue;
|
|
89
|
+
const decoded = decodeURIComponent(targetPath);
|
|
90
|
+
const absolute = path.resolve(path.dirname(file), decoded);
|
|
91
|
+
try {
|
|
92
|
+
if (!fs.existsSync(absolute)) {
|
|
93
|
+
const start = (match.index ?? 0) + match[0].indexOf(href);
|
|
94
|
+
out.push(diagnostic(offsets, start, start + href.length, `File does not exist: '${href}'`, "link.no-such-file"));
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// Ignore malformed/unsupported local paths.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const mermaidStarters = /^(?:---|graph\s+(?:TB|BT|RL|LR|TD)|flowchart\s+(?:TB|BT|RL|LR|TD)|sequenceDiagram|classDiagram(?:-v2)?|stateDiagram(?:-v2)?|erDiagram|journey|gantt|pie(?:\s+title\b)?|gitGraph|mindmap|timeline|quadrantChart|requirementDiagram|C4Context|C4Container|C4Component|C4Dynamic|sankey-beta|xyChart-beta|block-beta|packet-beta|architecture-beta)\b/i;
|
|
103
|
+
|
|
104
|
+
function mermaidBlocks(text: string): Array<{ content: string; startOffset: number; fenceStart: number; fenceEnd: number }> {
|
|
105
|
+
const blocks: Array<{ content: string; startOffset: number; fenceStart: number; fenceEnd: number }> = [];
|
|
106
|
+
const fencePattern = /^(```|~~~)\s*(?:mermaid|mmd)\b[^\r\n]*\r?\n([\s\S]*?)^\1\s*$/gim;
|
|
107
|
+
for (const match of text.matchAll(fencePattern)) {
|
|
108
|
+
const fenceStart = match.index ?? 0;
|
|
109
|
+
const contentStart = fenceStart + match[0].indexOf(match[2]);
|
|
110
|
+
blocks.push({ content: match[2], startOffset: contentStart, fenceStart, fenceEnd: fenceStart + match[0].length });
|
|
111
|
+
}
|
|
112
|
+
return blocks;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function addMermaidDiagnostics(file: string, text: string, offsets: LineOffsets, out: Diagnostic[]): void {
|
|
116
|
+
const extension = path.extname(file).toLocaleLowerCase();
|
|
117
|
+
const blocks = [".mmd", ".mermaid"].includes(extension)
|
|
118
|
+
? [{ content: text, startOffset: 0, fenceStart: 0, fenceEnd: text.length }]
|
|
119
|
+
: mermaidBlocks(text);
|
|
120
|
+
|
|
121
|
+
for (const block of blocks) {
|
|
122
|
+
const lines = block.content.split(/\r?\n/);
|
|
123
|
+
const firstIndex = lines.findIndex((line) => {
|
|
124
|
+
const trimmed = line.trim();
|
|
125
|
+
return trimmed.length > 0 && !trimmed.startsWith("%%");
|
|
126
|
+
});
|
|
127
|
+
if (firstIndex === -1) {
|
|
128
|
+
out.push(diagnostic(offsets, block.fenceStart, block.fenceEnd, "Mermaid diagram is empty", "mermaid.empty"));
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const beforeFirst = lines.slice(0, firstIndex).join("\n");
|
|
133
|
+
const firstOffset = block.startOffset + (beforeFirst ? beforeFirst.length + 1 : 0) + (lines[firstIndex].match(/^\s*/)?.[0].length ?? 0);
|
|
134
|
+
const firstLine = lines[firstIndex].trim();
|
|
135
|
+
if (!mermaidStarters.test(firstLine)) {
|
|
136
|
+
out.push(diagnostic(offsets, firstOffset, firstOffset + firstLine.length, "Mermaid diagram should start with a supported diagram type such as 'flowchart TD'", "mermaid.missing-diagram-type"));
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let runningOffset = block.startOffset;
|
|
141
|
+
for (const line of lines) {
|
|
142
|
+
const arrow = /\b[A-Za-z0-9_]+\s*->\s*[A-Za-z0-9_]+\b/.exec(line);
|
|
143
|
+
if (arrow) {
|
|
144
|
+
out.push(diagnostic(offsets, runningOffset + arrow.index, runningOffset + arrow.index + arrow[0].length, "Mermaid flowchart arrows use '-->' or another Mermaid arrow form, not '->'", "mermaid.invalid-arrow"));
|
|
145
|
+
}
|
|
146
|
+
runningOffset += line.length + 1;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function localMarkdownDiagnostics(file: string, text: string): Diagnostic[] {
|
|
152
|
+
const offsets = lineOffsets(text);
|
|
153
|
+
const diagnostics: Diagnostic[] = [];
|
|
154
|
+
addMarkdownLinkDiagnostics(file, text, offsets, diagnostics);
|
|
155
|
+
addMermaidDiagnostics(file, text, offsets, diagnostics);
|
|
156
|
+
return diagnostics;
|
|
157
|
+
}
|
package/package.json
CHANGED
|
@@ -1,339 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import { delimiter, isAbsolute, join } from "node:path";
|
|
6
|
-
import { parse as parseJsonc } from "jsonc-parser";
|
|
7
|
-
|
|
8
|
-
const BELL = "\x07";
|
|
9
|
-
const DEFAULT_IDLE_DELAY_MS = 250;
|
|
10
|
-
const IDLE_RETRY_DELAY_MS = 100;
|
|
11
|
-
const MAX_IDLE_RETRIES = 40;
|
|
12
|
-
const SUBAGENTS_LIVE_COUNT_EVENT = "pi-tools-suite:async-subagents:live-count";
|
|
13
|
-
const DEFAULT_NOTIFICATION_TITLE = "Pi";
|
|
14
|
-
const DEFAULT_NOTIFICATION_MESSAGE = "Session stopped";
|
|
15
|
-
const DEFAULT_ASK_USER_NOTIFICATION_MESSAGE = "Waiting for your answer";
|
|
16
|
-
const DEFAULT_MAC_SOUND = "Glass";
|
|
17
|
-
const TERMINAL_BELL_CONFIG_KEY = "terminalBell";
|
|
18
|
-
const SOUND_CONFIG_KEY = "sound";
|
|
19
|
-
|
|
20
|
-
const TERM_PROGRAM_BUNDLE_IDS: Record<string, string> = {
|
|
21
|
-
Apple_Terminal: "com.apple.Terminal",
|
|
22
|
-
iTerm: "com.googlecode.iterm2",
|
|
23
|
-
"iTerm.app": "com.googlecode.iterm2",
|
|
24
|
-
WezTerm: "com.github.wez.wezterm",
|
|
25
|
-
WarpTerminal: "dev.warp.Warp-Stable",
|
|
26
|
-
ghostty: "com.mitchellh.ghostty",
|
|
27
|
-
Ghostty: "com.mitchellh.ghostty",
|
|
28
|
-
kitty: "net.kovidgoyal.kitty",
|
|
29
|
-
Alacritty: "org.alacritty",
|
|
30
|
-
vscode: "com.microsoft.VSCode",
|
|
31
|
-
"vscode-insiders": "com.microsoft.VSCodeInsiders",
|
|
32
|
-
zed: "dev.zed.Zed",
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
type Timer = ReturnType<typeof setTimeout>;
|
|
36
|
-
|
|
37
|
-
type SubagentsLiveCountEvent = {
|
|
38
|
-
count?: unknown;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
function parseDelayMs(value: string | undefined): number {
|
|
42
|
-
if (value === undefined || value.trim() === "") return DEFAULT_IDLE_DELAY_MS;
|
|
43
|
-
const parsed = Number(value);
|
|
44
|
-
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_IDLE_DELAY_MS;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function isTruthyEnv(value: string | undefined): boolean {
|
|
48
|
-
if (value === undefined) return false;
|
|
49
|
-
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function extensionDisabled(): boolean {
|
|
53
|
-
return isTruthyEnv(process.env.HEADLESS) || isTruthyEnv(process.env.PI_TERMINAL_BELL_DISABLED);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function getPiToolsSuiteUserConfigPath(homeDir = homedir()): string {
|
|
57
|
-
return join(homeDir, ".config", "pi", "pi-tools-suite.jsonc");
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
61
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function readTerminalBellSoundConfig(configPath = getPiToolsSuiteUserConfigPath()): boolean | undefined {
|
|
65
|
-
if (!existsSync(configPath)) return undefined;
|
|
66
|
-
try {
|
|
67
|
-
const parsed = parseJsonc(readFileSync(configPath, "utf-8")) as unknown;
|
|
68
|
-
if (!isRecord(parsed)) return undefined;
|
|
69
|
-
const terminalBell = parsed[TERMINAL_BELL_CONFIG_KEY];
|
|
70
|
-
if (!isRecord(terminalBell)) return undefined;
|
|
71
|
-
const sound = terminalBell[SOUND_CONFIG_KEY];
|
|
72
|
-
return typeof sound === "boolean" ? sound : undefined;
|
|
73
|
-
} catch {
|
|
74
|
-
return undefined;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function terminalBellSoundEnabled(ctx: Pick<ExtensionContext, "hasUI">, configPath = getPiToolsSuiteUserConfigPath()): boolean {
|
|
79
|
-
if (process.env.PI_TERMINAL_BELL_SOUND === "0") return false;
|
|
80
|
-
if (process.env.PI_TERMINAL_BELL_SOUND === "1") return true;
|
|
81
|
-
const configured = readTerminalBellSoundConfig(configPath);
|
|
82
|
-
if (configured !== undefined) return configured;
|
|
83
|
-
return ctx.hasUI === true;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function terminalBellNotificationsEnabled(ctx: Pick<ExtensionContext, "hasUI">, configPath = getPiToolsSuiteUserConfigPath()): boolean {
|
|
87
|
-
if (!terminalBellSoundEnabled(ctx, configPath)) return false;
|
|
88
|
-
if (process.env.PI_TERMINAL_BELL_NOTIFY === "0") return false;
|
|
89
|
-
if (process.env.PI_TERMINAL_BELL_NOTIFY === "1") return true;
|
|
90
|
-
return ctx.hasUI === true;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function canRingTerminal(ctx: Pick<ExtensionContext, "hasUI">, configPath = getPiToolsSuiteUserConfigPath()): boolean {
|
|
94
|
-
if (!terminalBellSoundEnabled(ctx, configPath)) return false;
|
|
95
|
-
if (process.env.PI_TERMINAL_BELL === "0") return false;
|
|
96
|
-
if (process.env.PI_TERMINAL_BELL_FORCE === "1") return true;
|
|
97
|
-
return Boolean(process.stdout.isTTY || process.stderr.isTTY);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function writeBell(): void {
|
|
101
|
-
const stream = process.stdout.isTTY || !process.stderr.isTTY ? process.stdout : process.stderr;
|
|
102
|
-
stream.write(BELL);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function appleScriptString(value: string): string {
|
|
106
|
-
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function shellSingleQuote(value: string): string {
|
|
110
|
-
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function spawnDetached(command: string, args: string[]): void {
|
|
114
|
-
try {
|
|
115
|
-
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
116
|
-
child.on("error", () => {});
|
|
117
|
-
child.unref();
|
|
118
|
-
} catch {
|
|
119
|
-
// Best-effort user attention signal. Missing notification backends should not
|
|
120
|
-
// affect the agent loop or suppress the terminal bell.
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function findExecutable(command: string): string | undefined {
|
|
125
|
-
if (command.includes("/")) return existsSync(command) ? command : undefined;
|
|
126
|
-
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
127
|
-
if (!dir) continue;
|
|
128
|
-
const candidate = join(dir, command);
|
|
129
|
-
if (existsSync(candidate)) return candidate;
|
|
130
|
-
}
|
|
131
|
-
return undefined;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function resolveMacSoundPath(sound: string): string {
|
|
135
|
-
if (isAbsolute(sound)) return sound;
|
|
136
|
-
const fileName = sound.endsWith(".aiff") ? sound : `${sound}.aiff`;
|
|
137
|
-
return `/System/Library/Sounds/${fileName}`;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function detectMacActivationBundleId(): string | undefined {
|
|
141
|
-
const explicit = process.env.PI_TERMINAL_BELL_NOTIFY_ACTIVATE;
|
|
142
|
-
if (explicit === "0" || explicit === "false") return undefined;
|
|
143
|
-
if (explicit && explicit.trim() !== "") return explicit.trim();
|
|
144
|
-
|
|
145
|
-
// GUI apps that launch shells on macOS commonly export their own bundle id.
|
|
146
|
-
// This catches Terminal.app, iTerm2, Zed's terminal, VS Code terminals, etc.
|
|
147
|
-
const inheritedBundleId = process.env.__CFBundleIdentifier;
|
|
148
|
-
if (inheritedBundleId && inheritedBundleId.trim() !== "") return inheritedBundleId.trim();
|
|
149
|
-
|
|
150
|
-
const termProgram = process.env.TERM_PROGRAM;
|
|
151
|
-
if (!termProgram) return undefined;
|
|
152
|
-
return TERM_PROGRAM_BUNDLE_IDS[termProgram];
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function playAttentionSound(ctx: ExtensionContext): void {
|
|
156
|
-
if (!terminalBellSoundEnabled(ctx)) return;
|
|
157
|
-
if (process.platform !== "darwin") return;
|
|
158
|
-
const sound = process.env.PI_TERMINAL_BELL_SOUND && process.env.PI_TERMINAL_BELL_SOUND !== "1"
|
|
159
|
-
? process.env.PI_TERMINAL_BELL_SOUND
|
|
160
|
-
: DEFAULT_MAC_SOUND;
|
|
161
|
-
const soundPath = resolveMacSoundPath(sound);
|
|
162
|
-
if (!existsSync(soundPath)) return;
|
|
163
|
-
spawnDetached("/usr/bin/afplay", [soundPath]);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function notifySessionStopped(
|
|
167
|
-
ctx: ExtensionContext,
|
|
168
|
-
macActivationBundleId: string | undefined,
|
|
169
|
-
message = process.env.PI_TERMINAL_BELL_NOTIFY_MESSAGE ?? DEFAULT_NOTIFICATION_MESSAGE,
|
|
170
|
-
): void {
|
|
171
|
-
if (!terminalBellNotificationsEnabled(ctx)) return;
|
|
172
|
-
const title = process.env.PI_TERMINAL_BELL_NOTIFY_TITLE ?? DEFAULT_NOTIFICATION_TITLE;
|
|
173
|
-
|
|
174
|
-
if (process.platform === "darwin") {
|
|
175
|
-
const terminalNotifier = findExecutable(process.env.PI_TERMINAL_BELL_NOTIFIER ?? "terminal-notifier");
|
|
176
|
-
if (terminalNotifier) {
|
|
177
|
-
const args = ["-title", title, "-message", message];
|
|
178
|
-
const activate = macActivationBundleId;
|
|
179
|
-
if (activate) {
|
|
180
|
-
args.push("-activate", activate);
|
|
181
|
-
args.push("-execute", `/usr/bin/open -b ${shellSingleQuote(activate)}`);
|
|
182
|
-
|
|
183
|
-
// Do not pass -sender by default. On recent macOS versions it can make the
|
|
184
|
-
// notification look like it came from the target app, but then clicking the
|
|
185
|
-
// “Show” button may be handled as that app's own notification instead of
|
|
186
|
-
// terminal-notifier's -activate/-execute action.
|
|
187
|
-
if (process.env.PI_TERMINAL_BELL_NOTIFY_SENDER === "1") {
|
|
188
|
-
args.push("-sender", activate);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
spawnDetached(terminalNotifier, args);
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Bare osascript notifications are sent by Script Editor/osascript on macOS;
|
|
196
|
-
// clicking them can open Script Editor's file picker. Keep that backend opt-in
|
|
197
|
-
// and prefer terminal-notifier for clickable system notifications.
|
|
198
|
-
if (process.env.PI_TERMINAL_BELL_NOTIFY_OSASCRIPT !== "1") return;
|
|
199
|
-
spawnDetached("/usr/bin/osascript", [
|
|
200
|
-
"-e",
|
|
201
|
-
`display notification ${appleScriptString(message)} with title ${appleScriptString(title)}`,
|
|
202
|
-
]);
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (process.platform === "linux") {
|
|
207
|
-
spawnDetached("notify-send", [title, message]);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const ASK_USER_TOOL_NAMES = new Set(["ask_user", "ask_user_question", "question"]);
|
|
212
|
-
|
|
213
|
-
function isAskUserToolName(toolName: string): boolean {
|
|
214
|
-
return ASK_USER_TOOL_NAMES.has(toolName);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function isSubagentsWaitTool(toolName: string, args: unknown): boolean {
|
|
218
|
-
if (toolName === "async_subagents_wait") return true;
|
|
219
|
-
if (toolName !== "subagents") return false;
|
|
220
|
-
if (!args || typeof args !== "object") return false;
|
|
221
|
-
const action = (args as { action?: unknown }).action;
|
|
222
|
-
return typeof action === "string" && action.trim().toLowerCase() === "wait";
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function normalizeLiveCount(event: SubagentsLiveCountEvent): number | undefined {
|
|
226
|
-
if (typeof event.count !== "number" || !Number.isFinite(event.count)) return undefined;
|
|
227
|
-
return Math.max(0, Math.floor(event.count));
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
export default function terminalBell(pi: ExtensionAPI) {
|
|
231
|
-
if (extensionDisabled()) return;
|
|
232
|
-
|
|
233
|
-
let timer: Timer | undefined;
|
|
234
|
-
let lastCtx: ExtensionContext | undefined;
|
|
235
|
-
let deferredUntilSubagentsFinish = false;
|
|
236
|
-
let liveSubagentCount = 0;
|
|
237
|
-
const activeSubagentWaitToolCallIds = new Set<string>();
|
|
238
|
-
const notifiedAskUserToolCallIds = new Set<string>();
|
|
239
|
-
const idleDelayMs = parseDelayMs(process.env.PI_TERMINAL_BELL_DELAY_MS);
|
|
240
|
-
const macActivationBundleId = process.platform === "darwin" ? detectMacActivationBundleId() : undefined;
|
|
241
|
-
|
|
242
|
-
function clearTimer(): void {
|
|
243
|
-
if (!timer) return;
|
|
244
|
-
clearTimeout(timer);
|
|
245
|
-
timer = undefined;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function hasSubagentWork(): boolean {
|
|
249
|
-
return liveSubagentCount > 0 || activeSubagentWaitToolCallIds.size > 0;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function notifyAttention(ctx: ExtensionContext, message?: string): void {
|
|
253
|
-
if (canRingTerminal(ctx)) writeBell();
|
|
254
|
-
playAttentionSound(ctx);
|
|
255
|
-
notifySessionStopped(ctx, macActivationBundleId, message);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function attemptBell(ctx: ExtensionContext, attempt: number): void {
|
|
259
|
-
timer = undefined;
|
|
260
|
-
|
|
261
|
-
if (!ctx.isIdle()) {
|
|
262
|
-
if (attempt < MAX_IDLE_RETRIES) scheduleBell(ctx, IDLE_RETRY_DELAY_MS, attempt + 1);
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
if (ctx.hasPendingMessages()) return;
|
|
267
|
-
|
|
268
|
-
if (hasSubagentWork()) {
|
|
269
|
-
deferredUntilSubagentsFinish = true;
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
deferredUntilSubagentsFinish = false;
|
|
274
|
-
notifyAttention(ctx);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function scheduleBell(ctx: ExtensionContext, delayMs = idleDelayMs, attempt = 0): void {
|
|
278
|
-
lastCtx = ctx;
|
|
279
|
-
clearTimer();
|
|
280
|
-
timer = setTimeout(() => attemptBell(ctx, attempt), delayMs);
|
|
281
|
-
timer.unref?.();
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function notifyAskUserWaiting(toolCallId: string, ctx: ExtensionContext): void {
|
|
285
|
-
if (notifiedAskUserToolCallIds.has(toolCallId)) return;
|
|
286
|
-
notifiedAskUserToolCallIds.add(toolCallId);
|
|
287
|
-
notifyAttention(ctx, process.env.PI_TERMINAL_BELL_ASK_USER_NOTIFY_MESSAGE ?? DEFAULT_ASK_USER_NOTIFICATION_MESSAGE);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
pi.events.on(SUBAGENTS_LIVE_COUNT_EVENT, (data: unknown) => {
|
|
291
|
-
const event = data && typeof data === "object" ? data as SubagentsLiveCountEvent : {};
|
|
292
|
-
const count = normalizeLiveCount(event);
|
|
293
|
-
if (count === undefined) return;
|
|
294
|
-
liveSubagentCount = count;
|
|
295
|
-
if (count === 0 && deferredUntilSubagentsFinish && lastCtx) {
|
|
296
|
-
scheduleBell(lastCtx);
|
|
297
|
-
}
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
pi.on("agent_start", async () => {
|
|
301
|
-
clearTimer();
|
|
302
|
-
deferredUntilSubagentsFinish = false;
|
|
303
|
-
activeSubagentWaitToolCallIds.clear();
|
|
304
|
-
notifiedAskUserToolCallIds.clear();
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
pi.on("tool_execution_start", async (event, ctx) => {
|
|
308
|
-
if (isSubagentsWaitTool(event.toolName, event.args)) {
|
|
309
|
-
activeSubagentWaitToolCallIds.add(event.toolCallId);
|
|
310
|
-
}
|
|
311
|
-
if (isAskUserToolName(event.toolName)) {
|
|
312
|
-
notifyAskUserWaiting(event.toolCallId, ctx);
|
|
313
|
-
}
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
pi.on("tool_call", async (event, ctx) => {
|
|
317
|
-
if (isAskUserToolName(event.toolName)) {
|
|
318
|
-
notifyAskUserWaiting(event.toolCallId, ctx);
|
|
319
|
-
}
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
pi.on("tool_execution_end", async (event) => {
|
|
323
|
-
activeSubagentWaitToolCallIds.delete(event.toolCallId);
|
|
324
|
-
notifiedAskUserToolCallIds.delete(event.toolCallId);
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
pi.on("agent_end", async (_event, ctx) => {
|
|
328
|
-
scheduleBell(ctx);
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
pi.on("session_shutdown", async () => {
|
|
332
|
-
clearTimer();
|
|
333
|
-
lastCtx = undefined;
|
|
334
|
-
deferredUntilSubagentsFinish = false;
|
|
335
|
-
liveSubagentCount = 0;
|
|
336
|
-
activeSubagentWaitToolCallIds.clear();
|
|
337
|
-
notifiedAskUserToolCallIds.clear();
|
|
338
|
-
});
|
|
339
|
-
}
|