one-code-extension 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.
Files changed (153) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +202 -0
  3. package/agents/explore.md +24 -0
  4. package/agents/general-purpose.md +21 -0
  5. package/agents/plan.md +22 -0
  6. package/docs/decisions/auto-mode.md +526 -0
  7. package/docs/decisions/branding.md +127 -0
  8. package/docs/decisions/code-review-remediation.md +106 -0
  9. package/docs/decisions/compaction.md +140 -0
  10. package/docs/decisions/distribution.md +133 -0
  11. package/docs/decisions/hooks.md +64 -0
  12. package/docs/decisions/lsp.md +58 -0
  13. package/docs/decisions/mcp.md +104 -0
  14. package/docs/decisions/memory-state.md +170 -0
  15. package/docs/decisions/model-policy.md +126 -0
  16. package/docs/decisions/modes.md +178 -0
  17. package/docs/decisions/skills-plugins.md +56 -0
  18. package/docs/decisions/subagents-workflows.md +225 -0
  19. package/docs/decisions/system-prompt.md +98 -0
  20. package/docs/decisions/tools.md +407 -0
  21. package/docs/decisions/tui.md +202 -0
  22. package/docs/decisions.md +148 -0
  23. package/extensions/ask-user/index.ts +124 -0
  24. package/extensions/ask-user/questions.ts +60 -0
  25. package/extensions/auto-mode/actions.ts +78 -0
  26. package/extensions/auto-mode/classifier.ts +388 -0
  27. package/extensions/auto-mode/config.ts +237 -0
  28. package/extensions/auto-mode/decision-log.ts +60 -0
  29. package/extensions/auto-mode/defaults.ts +65 -0
  30. package/extensions/auto-mode/instructions.ts +74 -0
  31. package/extensions/auto-mode/model-picker.ts +225 -0
  32. package/extensions/auto-mode/model-select.ts +178 -0
  33. package/extensions/auto-mode/paths.ts +109 -0
  34. package/extensions/auto-mode/pause.ts +96 -0
  35. package/extensions/auto-mode/prompt.ts +307 -0
  36. package/extensions/auto-mode/rules.ts +96 -0
  37. package/extensions/auto-mode/safety-floor.ts +100 -0
  38. package/extensions/auto-mode/sensitive.ts +137 -0
  39. package/extensions/auto-mode/shell-analysis.ts +702 -0
  40. package/extensions/background/index.ts +405 -0
  41. package/extensions/background/registry.ts +81 -0
  42. package/extensions/background/wakeup.ts +41 -0
  43. package/extensions/bash/background.ts +136 -0
  44. package/extensions/bash/index.ts +149 -0
  45. package/extensions/branding/assistant-marker.ts +53 -0
  46. package/extensions/branding/index.ts +366 -0
  47. package/extensions/branding/prompt-editor.ts +42 -0
  48. package/extensions/branding/prompt-marker.ts +29 -0
  49. package/extensions/branding/startup.ts +152 -0
  50. package/extensions/claude-compat/index.ts +50 -0
  51. package/extensions/claude-context/index.ts +79 -0
  52. package/extensions/compaction/index.ts +231 -0
  53. package/extensions/compaction/prompt.ts +191 -0
  54. package/extensions/context-management/index.ts +147 -0
  55. package/extensions/effort/index.ts +216 -0
  56. package/extensions/effort/slider.ts +253 -0
  57. package/extensions/exit/index.ts +28 -0
  58. package/extensions/file-tracker/index.ts +103 -0
  59. package/extensions/file-tracker/tracker.ts +145 -0
  60. package/extensions/hooks/debug.ts +53 -0
  61. package/extensions/hooks/executor.ts +132 -0
  62. package/extensions/hooks/index.ts +310 -0
  63. package/extensions/hooks/matcher.ts +72 -0
  64. package/extensions/hooks/plugin-hooks.ts +79 -0
  65. package/extensions/hooks/protocol.ts +151 -0
  66. package/extensions/hooks/settings.ts +163 -0
  67. package/extensions/hooks/trust.ts +151 -0
  68. package/extensions/lib/branch-restore.ts +35 -0
  69. package/extensions/lib/claude-context.ts +148 -0
  70. package/extensions/lib/deferred.ts +119 -0
  71. package/extensions/lib/git.ts +14 -0
  72. package/extensions/lib/memory.ts +219 -0
  73. package/extensions/lib/model-policy.ts +436 -0
  74. package/extensions/lib/model-tier.ts +99 -0
  75. package/extensions/lib/notifications.ts +18 -0
  76. package/extensions/lib/paths.ts +26 -0
  77. package/extensions/lib/per-cwd.ts +21 -0
  78. package/extensions/lib/persisted-output.ts +70 -0
  79. package/extensions/lib/pi-version.ts +54 -0
  80. package/extensions/lib/plugins.ts +227 -0
  81. package/extensions/lib/reminders.ts +212 -0
  82. package/extensions/lib/scratchpad.ts +68 -0
  83. package/extensions/lib/tui-render.ts +522 -0
  84. package/extensions/lsp/client.ts +278 -0
  85. package/extensions/lsp/format.ts +46 -0
  86. package/extensions/lsp/index.ts +193 -0
  87. package/extensions/lsp/protocol.ts +64 -0
  88. package/extensions/lsp/servers.ts +79 -0
  89. package/extensions/mcp/client.ts +205 -0
  90. package/extensions/mcp/config.ts +199 -0
  91. package/extensions/mcp/index.ts +373 -0
  92. package/extensions/mcp/schema.ts +161 -0
  93. package/extensions/memory/index.ts +90 -0
  94. package/extensions/notebook/index.ts +77 -0
  95. package/extensions/notebook/notebook.ts +108 -0
  96. package/extensions/permissions/index.ts +876 -0
  97. package/extensions/permissions/matcher.ts +455 -0
  98. package/extensions/permissions/modes.ts +107 -0
  99. package/extensions/permissions/protected-paths.ts +125 -0
  100. package/extensions/permissions/settings.ts +98 -0
  101. package/extensions/plan-mode/index.ts +209 -0
  102. package/extensions/plan-mode/reminder.ts +33 -0
  103. package/extensions/plan-mode/slug.ts +28 -0
  104. package/extensions/plan-mode/viewer.ts +124 -0
  105. package/extensions/plugins/index.ts +115 -0
  106. package/extensions/plugins/template.ts +42 -0
  107. package/extensions/search-tools/index.ts +0 -0
  108. package/extensions/search-tools/tools.ts +24 -0
  109. package/extensions/skill/index.ts +170 -0
  110. package/extensions/subagents/agents.ts +126 -0
  111. package/extensions/subagents/child.ts +414 -0
  112. package/extensions/subagents/default-model.ts +122 -0
  113. package/extensions/subagents/index.ts +1119 -0
  114. package/extensions/subagents/model-select.ts +346 -0
  115. package/extensions/subagents/rpc-turns.ts +123 -0
  116. package/extensions/subagents/runs.ts +93 -0
  117. package/extensions/subagents/usage.ts +53 -0
  118. package/extensions/subagents/worktree.ts +69 -0
  119. package/extensions/system-prompt/environment.ts +35 -0
  120. package/extensions/system-prompt/index.ts +54 -0
  121. package/extensions/system-prompt/template.ts +103 -0
  122. package/extensions/system-prompt/tiers/common.ts +63 -0
  123. package/extensions/system-prompt/tiers/frontier.ts +24 -0
  124. package/extensions/system-prompt/tiers/low.ts +71 -0
  125. package/extensions/system-prompt/tiers/mid.ts +56 -0
  126. package/extensions/system-reminder/index.ts +37 -0
  127. package/extensions/tasks/index.ts +140 -0
  128. package/extensions/tasks/store.ts +174 -0
  129. package/extensions/todo/index.ts +133 -0
  130. package/extensions/tool-search/index.ts +168 -0
  131. package/extensions/tool-style/index.ts +84 -0
  132. package/extensions/web/index.ts +43 -0
  133. package/extensions/web-fetch/extract.ts +139 -0
  134. package/extensions/web-fetch/index.ts +291 -0
  135. package/extensions/web-fetch/summarize.ts +78 -0
  136. package/extensions/workflow/agent-session.ts +368 -0
  137. package/extensions/workflow/globals.ts +251 -0
  138. package/extensions/workflow/index.ts +364 -0
  139. package/extensions/workflow/journal.ts +75 -0
  140. package/extensions/workflow/permission-gate.ts +71 -0
  141. package/extensions/workflow/run-manager.ts +306 -0
  142. package/extensions/workflow/saved-workflows.ts +45 -0
  143. package/extensions/workflow/script-source.ts +201 -0
  144. package/extensions/workflow/types.ts +99 -0
  145. package/extensions/workflow/vm-runtime.ts +31 -0
  146. package/extensions/workflow/widget.ts +56 -0
  147. package/extensions/worktree/index.ts +266 -0
  148. package/extensions/worktree/rewrite.ts +59 -0
  149. package/package.json +101 -0
  150. package/themes/one-code-light.json +76 -0
  151. package/themes/one-code.json +76 -0
  152. package/types/app.d.ts +5 -0
  153. package/types/community-packages.d.ts +12 -0
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Minimal LSP client: spawn a server, sync file contents, collect diagnostics.
3
+ *
4
+ * Deliberately small — this exists to answer one question ("what does the
5
+ * language server say about this file right now?"), not to expose LSP.
6
+ *
7
+ * Design points that address the fragility of the package this replaces:
8
+ * - No dependencies, so no transitive protocol-version conflicts.
9
+ * - Content is pushed with didOpen/didChange before every diagnostics read, so
10
+ * the answer reflects the file on disk even if the edit was the session's
11
+ * first action.
12
+ * - `waitForDiagnostics` awaits the next publish for that document instead of
13
+ * sleeping a fixed interval, with a timeout and a last-known fallback.
14
+ * - Child handles are unref'd and shut down explicitly, so a one-shot `pi -p`
15
+ * run still exits.
16
+ */
17
+
18
+ import { type ChildProcess, spawn } from "node:child_process";
19
+ import { readFileSync } from "node:fs";
20
+ import { pathToFileURL } from "node:url";
21
+ import type { LspDiagnostic } from "./format.ts";
22
+ import { createReaderState, encodeMessage, type JsonRpcMessage, readMessages } from "./protocol.ts";
23
+ import type { ServerConfig } from "./servers.ts";
24
+
25
+ const INITIALIZE_TIMEOUT_MS = 15_000;
26
+ const DIAGNOSTICS_TIMEOUT_MS = 6_000;
27
+
28
+ export function pathToUri(path: string): string {
29
+ return pathToFileURL(path).toString();
30
+ }
31
+
32
+ function unrefStream(stream: unknown): void {
33
+ (stream as { unref?: () => void } | null | undefined)?.unref?.();
34
+ }
35
+
36
+ interface OpenDocument {
37
+ version: number;
38
+ text: string;
39
+ }
40
+
41
+ export class LspClient {
42
+ private child?: ChildProcess;
43
+ private reader = createReaderState();
44
+ private nextId = 1;
45
+ private pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>();
46
+ private diagnostics = new Map<string, LspDiagnostic[]>();
47
+ private waiters = new Map<string, Array<() => void>>();
48
+ private open = new Map<string, OpenDocument>();
49
+ private ready = false;
50
+ private failure?: string;
51
+
52
+ constructor(
53
+ readonly languageId: string,
54
+ private readonly config: ServerConfig,
55
+ private readonly root: string,
56
+ ) {}
57
+
58
+ get isRunning(): boolean {
59
+ return this.ready && !!this.child && this.child.exitCode === null;
60
+ }
61
+
62
+ get error(): string | undefined {
63
+ return this.failure;
64
+ }
65
+
66
+ get diagnosticsCount(): number {
67
+ let total = 0;
68
+ for (const list of this.diagnostics.values()) total += list.length;
69
+ return total;
70
+ }
71
+
72
+ async start(): Promise<void> {
73
+ if (this.child) return;
74
+
75
+ const child = spawn(this.config.command, this.config.args, {
76
+ cwd: this.root,
77
+ stdio: ["pipe", "pipe", "pipe"],
78
+ env: process.env,
79
+ });
80
+ this.child = child;
81
+
82
+ // Don't hold the event loop open once the agent is done. The stdio pipes
83
+ // are sockets at runtime, so they carry unref() even though the stream
84
+ // types don't declare it.
85
+ child.unref();
86
+ unrefStream(child.stdout);
87
+ unrefStream(child.stderr);
88
+ unrefStream(child.stdin);
89
+
90
+ let stderr = "";
91
+ child.stderr?.on("data", (chunk: Buffer) => {
92
+ stderr = (stderr + chunk.toString()).slice(-2000);
93
+ });
94
+ child.stdout?.on("data", (chunk: Buffer) => {
95
+ for (const message of readMessages(this.reader, chunk)) this.handle(message);
96
+ });
97
+ child.on("error", (error) => {
98
+ this.failure = `Could not start ${this.config.command}: ${error.message}`;
99
+ this.rejectAll(new Error(this.failure));
100
+ });
101
+ child.on("exit", (code, signal) => {
102
+ this.ready = false;
103
+ if (!this.failure && code !== 0) {
104
+ const detail = stderr.trim().split("\n").slice(-3).join(" ");
105
+ this.failure = `${this.config.command} exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`;
106
+ }
107
+ this.rejectAll(new Error(this.failure ?? "language server exited"));
108
+ });
109
+
110
+ await this.request(
111
+ "initialize",
112
+ {
113
+ processId: process.pid,
114
+ rootUri: pathToUri(this.root),
115
+ workspaceFolders: [{ uri: pathToUri(this.root), name: "workspace" }],
116
+ capabilities: {
117
+ textDocument: {
118
+ synchronization: { dynamicRegistration: false, didSave: false },
119
+ publishDiagnostics: { relatedInformation: false },
120
+ },
121
+ workspace: { workspaceFolders: true, configuration: true },
122
+ },
123
+ },
124
+ INITIALIZE_TIMEOUT_MS,
125
+ );
126
+
127
+ this.notify("initialized", {});
128
+ this.ready = true;
129
+ }
130
+
131
+ private handle(message: JsonRpcMessage): void {
132
+ if (message.method === "textDocument/publishDiagnostics") {
133
+ const params = message.params as { uri: string; diagnostics: LspDiagnostic[] };
134
+ this.diagnostics.set(params.uri, params.diagnostics ?? []);
135
+ const waiting = this.waiters.get(params.uri);
136
+ if (waiting) {
137
+ this.waiters.delete(params.uri);
138
+ for (const resolve of waiting) resolve();
139
+ }
140
+ return;
141
+ }
142
+
143
+ // Requests from the server: answer the ones that block startup.
144
+ if (message.id !== undefined && message.method) {
145
+ const result = message.method === "workspace/configuration" ? [{}] : null;
146
+ this.send({ id: message.id, result });
147
+ return;
148
+ }
149
+
150
+ if (message.id !== undefined) {
151
+ const entry = this.pending.get(Number(message.id));
152
+ if (!entry) return;
153
+ this.pending.delete(Number(message.id));
154
+ if (message.error) entry.reject(new Error(message.error.message));
155
+ else entry.resolve(message.result);
156
+ }
157
+ }
158
+
159
+ private send(payload: Record<string, unknown>): void {
160
+ this.child?.stdin?.write(encodeMessage(payload));
161
+ }
162
+
163
+ private notify(method: string, params: unknown): void {
164
+ this.send({ method, params });
165
+ }
166
+
167
+ request(method: string, params: unknown, timeoutMs = DIAGNOSTICS_TIMEOUT_MS): Promise<unknown> {
168
+ const id = this.nextId++;
169
+ return new Promise((resolve, reject) => {
170
+ const timer = setTimeout(() => {
171
+ this.pending.delete(id);
172
+ reject(new Error(`${method} timed out after ${timeoutMs}ms`));
173
+ }, timeoutMs);
174
+ timer.unref?.();
175
+ this.pending.set(id, {
176
+ resolve: (value) => {
177
+ clearTimeout(timer);
178
+ resolve(value);
179
+ },
180
+ reject: (error) => {
181
+ clearTimeout(timer);
182
+ reject(error);
183
+ },
184
+ });
185
+ this.send({ id, method, params });
186
+ });
187
+ }
188
+
189
+ private rejectAll(error: Error): void {
190
+ for (const [, entry] of this.pending) entry.reject(error);
191
+ this.pending.clear();
192
+ for (const [, waiting] of this.waiters) for (const resolve of waiting) resolve();
193
+ this.waiters.clear();
194
+ }
195
+
196
+ /** Push the file's current on-disk content and return its uri. */
197
+ syncFile(path: string): string {
198
+ const uri = pathToUri(path);
199
+ let text: string;
200
+ try {
201
+ text = readFileSync(path, "utf-8");
202
+ } catch {
203
+ return uri;
204
+ }
205
+
206
+ const existing = this.open.get(uri);
207
+ if (!existing) {
208
+ this.open.set(uri, { version: 1, text });
209
+ this.notify("textDocument/didOpen", {
210
+ textDocument: { uri, languageId: this.languageId, version: 1, text },
211
+ });
212
+ } else if (existing.text !== text) {
213
+ const version = existing.version + 1;
214
+ this.open.set(uri, { version, text });
215
+ this.notify("textDocument/didChange", {
216
+ textDocument: { uri, version },
217
+ contentChanges: [{ text }],
218
+ });
219
+ }
220
+ return uri;
221
+ }
222
+
223
+ /** Wait for the next publish for `uri`; resolves early if one already arrived. */
224
+ private waitForPublish(uri: string, timeoutMs: number): Promise<void> {
225
+ return new Promise((resolve) => {
226
+ const timer = setTimeout(() => resolve(), timeoutMs);
227
+ timer.unref?.();
228
+ const list = this.waiters.get(uri) ?? [];
229
+ list.push(() => {
230
+ clearTimeout(timer);
231
+ resolve();
232
+ });
233
+ this.waiters.set(uri, list);
234
+ });
235
+ }
236
+
237
+ /**
238
+ * Syncs the file, waits for the server's next diagnostics publication, and
239
+ * returns what it reported. Servers publish asynchronously and some republish
240
+ * nothing when a file is unchanged, hence the timeout plus last-known
241
+ * fallback.
242
+ */
243
+ async getDiagnostics(path: string, timeoutMs = DIAGNOSTICS_TIMEOUT_MS): Promise<LspDiagnostic[]> {
244
+ const uri = pathToUri(path);
245
+ const hadContent = this.open.get(uri)?.text;
246
+ const synced = this.syncFile(path);
247
+ const changed = this.open.get(uri)?.text !== hadContent;
248
+
249
+ if (changed || !this.diagnostics.has(synced)) {
250
+ await this.waitForPublish(synced, timeoutMs);
251
+ }
252
+ return this.diagnostics.get(synced) ?? [];
253
+ }
254
+
255
+ async stop(): Promise<void> {
256
+ const child = this.child;
257
+ if (!child) return;
258
+ this.child = undefined;
259
+ this.ready = false;
260
+ try {
261
+ this.send({ id: this.nextId++, method: "shutdown", params: null });
262
+ this.notify("exit", null);
263
+ } catch {
264
+ // Server may already be gone.
265
+ }
266
+ await new Promise<void>((resolve) => {
267
+ const timer = setTimeout(() => {
268
+ child.kill("SIGKILL");
269
+ resolve();
270
+ }, 1000);
271
+ timer.unref?.();
272
+ child.once("exit", () => {
273
+ clearTimeout(timer);
274
+ resolve();
275
+ });
276
+ });
277
+ }
278
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Diagnostic shaping for the model (pure).
3
+ */
4
+
5
+ export interface LspDiagnostic {
6
+ range: { start: { line: number; character: number }; end?: { line: number; character: number } };
7
+ severity?: number;
8
+ code?: string | number;
9
+ source?: string;
10
+ message: string;
11
+ }
12
+
13
+ export type SeverityFilter = "error" | "warning" | "all";
14
+
15
+ const SEVERITY_NAMES: Record<number, string> = { 1: "error", 2: "warning", 3: "info", 4: "hint" };
16
+
17
+ export function severityName(severity: number | undefined): string {
18
+ return SEVERITY_NAMES[severity ?? 1] ?? "error";
19
+ }
20
+
21
+ export function filterDiagnostics(diagnostics: LspDiagnostic[], filter: SeverityFilter): LspDiagnostic[] {
22
+ if (filter === "all") return diagnostics;
23
+ const max = filter === "error" ? 1 : 2;
24
+ return diagnostics.filter((d) => (d.severity ?? 1) <= max);
25
+ }
26
+
27
+ /** One diagnostic as `path:line:col severity: message (code) [source]`, 1-indexed. */
28
+ export function formatDiagnostic(relPath: string, diagnostic: LspDiagnostic): string {
29
+ const line = diagnostic.range.start.line + 1;
30
+ const column = diagnostic.range.start.character + 1;
31
+ const code = diagnostic.code !== undefined ? ` (${diagnostic.code})` : "";
32
+ const source = diagnostic.source ? ` [${diagnostic.source}]` : "";
33
+ return `${relPath}:${line}:${column} ${severityName(diagnostic.severity)}: ${diagnostic.message}${code}${source}`;
34
+ }
35
+
36
+ export function formatDiagnostics(relPath: string, diagnostics: LspDiagnostic[], limit = 20): string {
37
+ if (diagnostics.length === 0) return "No diagnostics.";
38
+ const shown = diagnostics.slice(0, limit).map((d) => formatDiagnostic(relPath, d));
39
+ const errors = diagnostics.filter((d) => (d.severity ?? 1) === 1).length;
40
+ const warnings = diagnostics.filter((d) => d.severity === 2).length;
41
+ const counts = [errors ? `${errors} error${errors === 1 ? "" : "s"}` : "", warnings ? `${warnings} warning${warnings === 1 ? "" : "s"}` : ""]
42
+ .filter(Boolean)
43
+ .join(", ");
44
+ const omitted = diagnostics.length > limit ? `\n… ${diagnostics.length - limit} more` : "";
45
+ return `${counts || `${diagnostics.length} diagnostics`}\n\n${shown.join("\n")}${omitted}`;
46
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * lsp extension — Claude Code's post-edit diagnostics.
3
+ *
4
+ * Two behaviors, no more:
5
+ * 1. After a successful `edit` or `write`, error diagnostics for that file are
6
+ * appended to the tool result, so the model immediately sees what it broke.
7
+ * 2. An `lsp_diagnostics` tool (deferred behind `tool_search`) for asking about
8
+ * a file on demand.
9
+ *
10
+ * Claude Code has no other LSP tools; navigation goes through grep/find. Adding
11
+ * hover or definition later is a small `client.request(...)` call each, if the
12
+ * need appears.
13
+ */
14
+
15
+ import { relative } from "node:path";
16
+ import { StringEnum } from "@earendil-works/pi-ai";
17
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import { Type } from "typebox";
19
+ import { DEFER_CHANNEL } from "../lib/deferred.ts";
20
+ import { ccToolRenderers } from "../lib/tui-render.ts";
21
+ import { LspClient } from "./client.ts";
22
+ import { filterDiagnostics, formatDiagnostics, type SeverityFilter } from "./format.ts";
23
+ import { findProjectRoot, serverForPath, typescriptPreflight } from "./servers.ts";
24
+
25
+ /** Only errors are pushed unasked; warnings would be noise on every edit. */
26
+ const AUTO_APPEND_LIMIT = 10;
27
+
28
+ /** The (match, project root, client-map key) triple every entry point needs; undefined when the path has no configured server. */
29
+ function resolveServer(
30
+ path: string,
31
+ cwd: string,
32
+ ): { match: NonNullable<ReturnType<typeof serverForPath>>; root: string; key: string } | undefined {
33
+ const match = serverForPath(path);
34
+ if (!match) return undefined;
35
+ const root = findProjectRoot(path, match.config.rootMarkers, cwd);
36
+ const key = `${match.languageId}:${root}`;
37
+ return { match, root, key };
38
+ }
39
+
40
+ export default function lspExtension(pi: ExtensionAPI) {
41
+ const clients = new Map<string, LspClient>();
42
+ const startFailures = new Map<string, string>();
43
+ const warned = new Set<string>();
44
+
45
+ const clientFor = async (path: string, cwd: string): Promise<LspClient | undefined> => {
46
+ const resolved = resolveServer(path, cwd);
47
+ if (!resolved) return undefined;
48
+ const { match, root, key } = resolved;
49
+
50
+ const failure = startFailures.get(key);
51
+ if (failure) return undefined;
52
+
53
+ const existing = clients.get(key);
54
+ if (existing?.isRunning) return existing;
55
+ if (existing) {
56
+ // Crashed mid-session. Record why so downstream reports the real cause
57
+ // (a server that was running and died) instead of falling through to
58
+ // "install <command>" advice for an already-installed server — and so
59
+ // the one-time post-edit warning still fires. Don't respawn in a loop.
60
+ startFailures.set(key, existing.error ?? "language server stopped unexpectedly");
61
+ return undefined;
62
+ }
63
+
64
+ if (match.languageId.startsWith("typescript") || match.languageId.startsWith("javascript")) {
65
+ const problem = typescriptPreflight(root);
66
+ if (problem) {
67
+ startFailures.set(key, problem);
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ const client = new LspClient(match.languageId, match.config, root);
73
+ clients.set(key, client);
74
+ try {
75
+ await client.start();
76
+ return client;
77
+ } catch (error) {
78
+ startFailures.set(key, client.error ?? (error as Error).message);
79
+ return undefined;
80
+ }
81
+ };
82
+
83
+ /** One-time notice per root so a missing server doesn't degrade silently. */
84
+ const reportFailureOnce = (path: string, cwd: string, notify: (message: string) => void) => {
85
+ const resolved = resolveServer(path, cwd);
86
+ if (!resolved) return;
87
+ const { match, key } = resolved;
88
+ const failure = startFailures.get(key);
89
+ if (failure && !warned.has(key)) {
90
+ warned.add(key);
91
+ // Server errors run to paragraphs; the transcript gets one line and
92
+ // /lsp keeps the full status.
93
+ const brief = failure.split("\n")[0].replace(/\s+/g, " ").trim();
94
+ const capped = brief.length > 100 ? `${brief.slice(0, 99)}…` : brief;
95
+ notify(`LSP unavailable for ${match.languageId}: ${capped} (/lsp for status)`);
96
+ }
97
+ };
98
+
99
+ pi.on("tool_result", async (event, ctx) => {
100
+ if (event.isError) return;
101
+ if (event.toolName !== "edit" && event.toolName !== "write") return;
102
+
103
+ const path = (event.input as { path?: unknown }).path;
104
+ if (typeof path !== "string") return;
105
+
106
+ const client = await clientFor(path, ctx.cwd);
107
+ if (!client) {
108
+ reportFailureOnce(path, ctx.cwd, (message) => {
109
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
110
+ });
111
+ return;
112
+ }
113
+
114
+ const errors = filterDiagnostics(await client.getDiagnostics(path), "error");
115
+ if (errors.length === 0) return;
116
+
117
+ const relPath = relative(ctx.cwd, path) || path;
118
+ const summary = formatDiagnostics(relPath, errors, AUTO_APPEND_LIMIT);
119
+ const existing = event.content ?? [];
120
+ return {
121
+ content: [...existing, { type: "text", text: `\n<diagnostics>\n${summary}\n</diagnostics>` }],
122
+ };
123
+ });
124
+
125
+ pi.registerTool({
126
+ name: "lsp_diagnostics",
127
+ label: "Diagnostics",
128
+ ...ccToolRenderers("Diagnostics"),
129
+ description:
130
+ "Ask the language server for diagnostics (type errors, warnings) on a file. Reflects the file's current contents. Supported: TypeScript/JavaScript, Python, Go, Rust, Java — when that language's server is installed.",
131
+ parameters: Type.Object({
132
+ path: Type.String({ description: "File to analyse (absolute or workspace-relative)" }),
133
+ severity: Type.Optional(
134
+ StringEnum(["error", "warning", "all"] as const, { description: "Minimum severity (default: all)" }),
135
+ ),
136
+ }),
137
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
138
+ const path = params.path.startsWith("/") ? params.path : `${ctx.cwd}/${params.path}`;
139
+ const client = await clientFor(path, ctx.cwd);
140
+ if (!client) {
141
+ const resolved = resolveServer(path, ctx.cwd);
142
+ const match = resolved?.match;
143
+ const failure = resolved ? startFailures.get(resolved.key) : undefined;
144
+ return {
145
+ content: [
146
+ {
147
+ type: "text",
148
+ text:
149
+ failure ??
150
+ (match
151
+ ? `No language server available for ${match.languageId}. Install ${match.config.command}.`
152
+ : `No language server is configured for this file type.`),
153
+ },
154
+ ],
155
+ details: { available: false },
156
+ // A language was recognised but its server is unavailable/failed —
157
+ // that is an error, not a clean "no diagnostics". Only a genuinely
158
+ // unsupported filetype (no match) is a non-error outcome.
159
+ isError: Boolean(match),
160
+ };
161
+ }
162
+
163
+ const all = await client.getDiagnostics(path);
164
+ const filtered = filterDiagnostics(all, (params.severity ?? "all") as SeverityFilter);
165
+ const relPath = relative(ctx.cwd, path) || path;
166
+ return {
167
+ content: [{ type: "text", text: formatDiagnostics(relPath, filtered) }],
168
+ details: { count: filtered.length, languageId: client.languageId },
169
+ };
170
+ },
171
+ });
172
+
173
+ pi.registerCommand("lsp", {
174
+ description: "Show language server status",
175
+ handler: async (_args, ctx) => {
176
+ const lines = [...clients.entries()].map(
177
+ ([key, client]) => `${client.isRunning ? "running" : "stopped"} ${key} (${client.diagnosticsCount} diagnostics)`,
178
+ );
179
+ for (const [key, failure] of startFailures) lines.push(`failed ${key}: ${failure}`);
180
+ ctx.ui.notify(lines.length ? lines.join("\n") : "No language servers started.", "info");
181
+ },
182
+ });
183
+
184
+ pi.on("session_shutdown", async () => {
185
+ await Promise.all([...clients.values()].map((client) => client.stop()));
186
+ clients.clear();
187
+ });
188
+
189
+ pi.events.emit(DEFER_CHANNEL, {
190
+ name: "lsp_diagnostics",
191
+ keywords: ["diagnostics", "errors", "type error", "typecheck", "compile", "lint", "lsp"],
192
+ });
193
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * LSP base protocol framing (pure).
3
+ *
4
+ * Messages are `Content-Length: <n>\r\n\r\n<n bytes of JSON>`. One chunk from a
5
+ * server's stdout may contain several messages, or half of one, so the reader
6
+ * keeps a buffer across calls.
7
+ */
8
+
9
+ export interface JsonRpcMessage {
10
+ jsonrpc: "2.0";
11
+ id?: number | string;
12
+ method?: string;
13
+ params?: unknown;
14
+ result?: unknown;
15
+ error?: { code: number; message: string };
16
+ }
17
+
18
+ export function encodeMessage(message: Record<string, unknown>): Buffer {
19
+ const body = Buffer.from(JSON.stringify({ jsonrpc: "2.0", ...message }), "utf-8");
20
+ return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "ascii"), body]);
21
+ }
22
+
23
+ export interface ReaderState {
24
+ buffer: Buffer;
25
+ }
26
+
27
+ export function createReaderState(): ReaderState {
28
+ return { buffer: Buffer.alloc(0) };
29
+ }
30
+
31
+ /**
32
+ * Appends a chunk and returns every complete message now available. Malformed
33
+ * headers are skipped rather than throwing, so one bad frame cannot wedge the
34
+ * stream.
35
+ */
36
+ export function readMessages(state: ReaderState, chunk: Buffer): JsonRpcMessage[] {
37
+ state.buffer = state.buffer.length === 0 ? chunk : Buffer.concat([state.buffer, chunk]);
38
+ const messages: JsonRpcMessage[] = [];
39
+
40
+ while (true) {
41
+ const headerEnd = state.buffer.indexOf("\r\n\r\n");
42
+ if (headerEnd === -1) return messages;
43
+
44
+ const header = state.buffer.subarray(0, headerEnd).toString("ascii");
45
+ const match = header.match(/content-length:\s*(\d+)/i);
46
+ if (!match) {
47
+ // Unparseable header: drop it and resynchronise.
48
+ state.buffer = state.buffer.subarray(headerEnd + 4);
49
+ continue;
50
+ }
51
+
52
+ const length = Number(match[1]);
53
+ const bodyStart = headerEnd + 4;
54
+ if (state.buffer.length < bodyStart + length) return messages;
55
+
56
+ const body = state.buffer.subarray(bodyStart, bodyStart + length).toString("utf-8");
57
+ state.buffer = state.buffer.subarray(bodyStart + length);
58
+ try {
59
+ messages.push(JSON.parse(body) as JsonRpcMessage);
60
+ } catch {
61
+ // Ignore a body that isn't JSON; the stream stays aligned.
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Language and server detection (pure).
3
+ *
4
+ * The server command table follows the one in the MIT-licensed
5
+ * `pi-lsp-extension` package, which is a good curated starting point.
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { dirname, extname, join } from "node:path";
10
+
11
+ export interface ServerConfig {
12
+ command: string;
13
+ args: string[];
14
+ /** Files that mark the project root for this language. */
15
+ rootMarkers: string[];
16
+ }
17
+
18
+ const LANGUAGE_BY_EXTENSION: Record<string, string> = {
19
+ ".ts": "typescript",
20
+ ".tsx": "typescriptreact",
21
+ ".mts": "typescript",
22
+ ".cts": "typescript",
23
+ ".js": "javascript",
24
+ ".jsx": "javascriptreact",
25
+ ".mjs": "javascript",
26
+ ".cjs": "javascript",
27
+ ".py": "python",
28
+ ".pyi": "python",
29
+ ".go": "go",
30
+ ".rs": "rust",
31
+ ".java": "java",
32
+ };
33
+
34
+ const TS_MARKERS = ["tsconfig.json", "jsconfig.json", "package.json"];
35
+
36
+ export const SERVERS: Record<string, ServerConfig> = {
37
+ typescript: { command: "typescript-language-server", args: ["--stdio"], rootMarkers: TS_MARKERS },
38
+ typescriptreact: { command: "typescript-language-server", args: ["--stdio"], rootMarkers: TS_MARKERS },
39
+ javascript: { command: "typescript-language-server", args: ["--stdio"], rootMarkers: TS_MARKERS },
40
+ javascriptreact: { command: "typescript-language-server", args: ["--stdio"], rootMarkers: TS_MARKERS },
41
+ python: { command: "pyright-langserver", args: ["--stdio"], rootMarkers: ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt"] },
42
+ go: { command: "gopls", args: ["serve"], rootMarkers: ["go.mod", "go.work"] },
43
+ rust: { command: "rust-analyzer", args: [], rootMarkers: ["Cargo.toml"] },
44
+ java: { command: "jdtls", args: [], rootMarkers: ["pom.xml", "build.gradle", "build.gradle.kts"] },
45
+ };
46
+
47
+ export function languageIdForPath(path: string): string | undefined {
48
+ return LANGUAGE_BY_EXTENSION[extname(path).toLowerCase()];
49
+ }
50
+
51
+ export function serverForPath(path: string): { languageId: string; config: ServerConfig } | undefined {
52
+ const languageId = languageIdForPath(path);
53
+ if (!languageId) return undefined;
54
+ const config = SERVERS[languageId];
55
+ return config ? { languageId, config } : undefined;
56
+ }
57
+
58
+ /** Nearest ancestor directory containing one of the language's root markers. */
59
+ export function findProjectRoot(startPath: string, markers: string[], fallback: string): string {
60
+ let dir = dirname(startPath);
61
+ while (true) {
62
+ if (markers.some((marker) => existsSync(join(dir, marker)))) return dir;
63
+ const parent = dirname(dir);
64
+ if (parent === dir) return fallback;
65
+ dir = parent;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * TypeScript 7's native compiler dropped `lib/tsserver.js`, which
71
+ * typescript-language-server requires. Detect that up front so we can say so
72
+ * instead of reporting a mystery server failure.
73
+ */
74
+ export function typescriptPreflight(root: string): string | undefined {
75
+ const localTypescript = join(root, "node_modules", "typescript");
76
+ if (!existsSync(localTypescript)) return undefined;
77
+ if (existsSync(join(localTypescript, "lib", "tsserver.js"))) return undefined;
78
+ return "The project's local TypeScript has no lib/tsserver.js (TypeScript 7's native compiler removed it), so typescript-language-server cannot analyse this project. Install typescript 5.x as a dev dependency for LSP diagnostics.";
79
+ }