one-code-extension 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +189 -94
- package/docs/guide/README.md +3 -2
- package/docs/guide/bring-your-claude-code-setup.md +7 -5
- package/docs/guide/configuration.md +7 -5
- package/docs/guide/differences-from-claude-code.md +25 -14
- package/docs/guide/doctor.md +5 -3
- package/docs/guide/hooks.md +20 -6
- package/docs/guide/installation.md +62 -12
- package/docs/guide/permissions-modes-and-auto-mode.md +46 -28
- package/docs/guide/providers-and-models.md +12 -10
- package/docs/guide/reference.md +9 -4
- package/docs/guide/sessions-and-context.md +23 -3
- package/docs/guide/skills-plugins-and-mcp.md +7 -5
- package/docs/guide/subagents-and-workflows.md +13 -11
- package/docs/guide/tasks-and-background-work.md +10 -8
- package/docs/guide/terminal-interface.md +7 -5
- package/docs/guide/tools.md +29 -5
- package/docs/guide/troubleshooting.md +26 -14
- package/docs/guide/windows.md +184 -0
- package/extensions/auto-mode/actions.ts +3 -1
- package/extensions/auto-mode/paths.ts +21 -23
- package/extensions/auto-mode/safety-floor.ts +44 -6
- package/extensions/auto-mode/shell-analysis.ts +25 -10
- package/extensions/auto-mode/transcript.ts +15 -5
- package/extensions/background/index.ts +83 -34
- package/extensions/background/monitor-batch.ts +7 -5
- package/extensions/background/wakeup.ts +18 -24
- package/extensions/bash/background.ts +19 -10
- package/extensions/bash/guards.ts +36 -16
- package/extensions/bash/index.ts +36 -166
- package/extensions/branding/index.ts +35 -4
- package/extensions/btw/index.ts +207 -0
- package/extensions/btw/panel.ts +179 -0
- package/extensions/btw/prompt.ts +45 -0
- package/extensions/clear/index.ts +15 -0
- package/extensions/compaction/fit.ts +9 -6
- package/extensions/doctor/build.ts +22 -2
- package/extensions/doctor/dependencies.ts +63 -31
- package/extensions/doctor/fix-prompt.ts +12 -4
- package/extensions/doctor/index.ts +1 -0
- package/extensions/doctor/models.ts +2 -1
- package/extensions/doctor/report.ts +2 -3
- package/extensions/doctor/update-lookup.ts +19 -11
- package/extensions/effort/index.ts +2 -1
- package/extensions/footer/footer-line.ts +19 -0
- package/extensions/footer/index.ts +25 -5
- package/extensions/hooks/executor.ts +84 -18
- package/extensions/hooks/index.ts +1 -0
- package/extensions/hooks/matcher.ts +23 -9
- package/extensions/hooks/settings.ts +15 -0
- package/extensions/lib/branch-restore.ts +12 -0
- package/extensions/lib/claude-context.ts +2 -3
- package/extensions/lib/claude-settings.ts +19 -0
- package/extensions/lib/command-launch.ts +71 -0
- package/extensions/lib/deferred.ts +173 -33
- package/extensions/lib/git.ts +16 -0
- package/extensions/lib/keys.ts +38 -0
- package/extensions/lib/local-command.ts +112 -0
- package/extensions/lib/model-facts.generated.json +263 -68
- package/extensions/lib/model-policy.ts +13 -0
- package/extensions/lib/model-tier.ts +16 -5
- package/extensions/lib/notifications.ts +547 -53
- package/extensions/lib/paths.ts +130 -2
- package/extensions/lib/pi-ai-estimate.ts +57 -26
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/process-tree.ts +131 -5
- package/extensions/lib/reminders.ts +80 -27
- package/extensions/lib/scratchpad.ts +11 -10
- package/extensions/lib/shell-spawn.ts +298 -0
- package/extensions/lib/shell-tool.ts +252 -0
- package/extensions/lib/shell-tools.ts +13 -0
- package/extensions/lib/side-call.ts +76 -0
- package/extensions/lib/tui-render.ts +62 -11
- package/extensions/lib/update-check.mjs +155 -0
- package/extensions/lib/usage-bus.ts +1 -1
- package/extensions/lib/which.ts +62 -0
- package/extensions/lib/worktree-channel.ts +18 -0
- package/extensions/lib/worktree-isolation.ts +2 -1
- package/extensions/lsp/client.ts +14 -3
- package/extensions/lsp/index.ts +12 -5
- package/extensions/lsp/install-hints.ts +33 -3
- package/extensions/lsp/plugin-servers.ts +2 -2
- package/extensions/lsp/servers.ts +29 -7
- package/extensions/mcp/index.ts +3 -2
- package/extensions/memory/entries.ts +9 -6
- package/extensions/memory/index.ts +3 -2
- package/extensions/memory/open-external.ts +4 -1
- package/extensions/model-default/index.ts +21 -2
- package/extensions/permissions/index.ts +57 -28
- package/extensions/permissions/matcher.ts +120 -33
- package/extensions/permissions/modes.ts +14 -6
- package/extensions/permissions/powershell-rules.ts +549 -0
- package/extensions/permissions/session-grant.ts +9 -9
- package/extensions/plugins/index.ts +3 -2
- package/extensions/plugins/install/install.ts +1 -1
- package/extensions/powershell/description.ts +139 -0
- package/extensions/powershell/guards.ts +91 -0
- package/extensions/powershell/index.ts +89 -0
- package/extensions/powershell/policy.ts +91 -0
- package/extensions/recap/index.ts +10 -24
- package/extensions/recap/prompt.ts +3 -3
- package/extensions/session-title/index.ts +120 -0
- package/extensions/session-title/title.ts +111 -0
- package/extensions/skill/index.ts +40 -18
- package/extensions/skill/invoke.ts +1 -0
- package/extensions/subagents/hand-back-review.ts +10 -12
- package/extensions/subagents/index.ts +221 -78
- package/extensions/subagents/model-select.ts +75 -7
- package/extensions/system-prompt/environment.ts +14 -1
- package/extensions/system-reminder/index.ts +9 -0
- package/extensions/tasks/index.ts +2 -1
- package/extensions/tool-search/index.ts +41 -20
- package/extensions/workflow/agent-session.ts +6 -4
- package/extensions/workflow/index.ts +28 -5
- package/extensions/worktree/guards.ts +5 -4
- package/extensions/worktree/index.ts +15 -2
- package/extensions/worktree/rewrite.ts +17 -0
- package/package.json +7 -4
- package/types/app.d.ts +18 -2
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared pieces of a stateless side-model call — a one-off completion made over
|
|
3
|
+
* (a slice of) the current conversation, outside the main agent loop. Used by
|
|
4
|
+
* the recap extension (the "while you were away" summary) and the btw extension
|
|
5
|
+
* (Claude Code's `/btw` side question). Kept free of runtime pi imports (only a
|
|
6
|
+
* pi-ai type) so it stays unit-testable and importable from pure prompt modules.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Tool } from "@earendil-works/pi-ai";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Name-only tool declarations: enough for a provider to accept the `tool_use`
|
|
13
|
+
* blocks already in a shared history, without the descriptions and schemas. A
|
|
14
|
+
* side call never invokes a tool (its prompt asks for text), and the empty
|
|
15
|
+
* schema keeps the model answering in text on strict providers that reject a
|
|
16
|
+
* history carrying `tool_use` blocks with no tools declared.
|
|
17
|
+
*/
|
|
18
|
+
export function toolStubs(names: readonly string[], reason = "Unavailable for this call; answer in text."): Tool[] {
|
|
19
|
+
return names.map(
|
|
20
|
+
(name) =>
|
|
21
|
+
({
|
|
22
|
+
name,
|
|
23
|
+
description: reason,
|
|
24
|
+
parameters: { type: "object", properties: {} },
|
|
25
|
+
}) as unknown as Tool,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Trim a message history forward to the first clean turn boundary — a `user`
|
|
31
|
+
* prompt or an `assistant` reply. A raw history can begin mid-exchange with a
|
|
32
|
+
* leading `tool_result` message whose matching `assistant` `tool_use` is absent,
|
|
33
|
+
* which strict providers (e.g. Anthropic) reject as an orphan `tool_result`. An
|
|
34
|
+
* assistant start is safe: its own tool results follow. When there is no
|
|
35
|
+
* user/assistant message at all, nothing is safe to send and this returns empty.
|
|
36
|
+
* Generic over `{ role }` so it is testable with plain objects.
|
|
37
|
+
*/
|
|
38
|
+
export function trimToTurnBoundary<T extends { role: string }>(messages: readonly T[]): T[] {
|
|
39
|
+
const start = messages.findIndex((message) => message.role === "user" || message.role === "assistant");
|
|
40
|
+
return start === -1 ? [] : messages.slice(start);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The placeholder left in a message whose only content was an image we removed. */
|
|
44
|
+
export const IMAGE_OMITTED_TEXT = "[image omitted]";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Drop image content blocks from a message history, so a side call can run on a
|
|
48
|
+
* text-only model without the images the conversation carried breaking the
|
|
49
|
+
* request. Used by the recap and btw side calls, which reuse the exact messages
|
|
50
|
+
* the session last sent — those can hold `image` blocks (pasted images, an image
|
|
51
|
+
* a Read returned), and neither call needs them: recap summarises "what
|
|
52
|
+
* happened" and btw answers a text question. The classifier renders the
|
|
53
|
+
* transcript to text (never blocks) and the web_fetch reader sends page text, so
|
|
54
|
+
* they need no stripping. A message left with no content becomes a single
|
|
55
|
+
* placeholder text block, so it stays a valid turn on strict providers. Purely
|
|
56
|
+
* structural (any `{ role, content }` message) to stay free of runtime pi
|
|
57
|
+
* imports; assistant messages carry no images and pass through untouched.
|
|
58
|
+
* See `docs/decisions/model-policy.md`.
|
|
59
|
+
*/
|
|
60
|
+
export function stripImageBlocks<M extends { role: string; content: unknown }>(messages: readonly M[]): M[] {
|
|
61
|
+
return messages.map((message) => {
|
|
62
|
+
const content = message.content;
|
|
63
|
+
if (!Array.isArray(content) || !content.some((block) => (block as { type?: string })?.type === "image")) return message;
|
|
64
|
+
const kept = content.filter((block) => (block as { type?: string })?.type !== "image");
|
|
65
|
+
return { ...message, content: kept.length > 0 ? kept : [{ type: "text", text: IMAGE_OMITTED_TEXT }] } as M;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Join the text blocks of a completion result into one trimmed string. */
|
|
70
|
+
export function answerText(content: readonly { type: string; text?: string }[]): string {
|
|
71
|
+
return content
|
|
72
|
+
.filter((block): block is { type: "text"; text: string } => block.type === "text" && typeof block.text === "string")
|
|
73
|
+
.map((block) => block.text)
|
|
74
|
+
.join("\n")
|
|
75
|
+
.trim();
|
|
76
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* every line goes through `truncateLine` — never skip it.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { notificationBody } from "./notifications.ts";
|
|
18
|
+
import { framesToText, notificationBody, parseNotificationFrames } from "./notifications.ts";
|
|
19
19
|
import { fitPainted, hardWrapColumns, sliceColumns, visibleWidth } from "./text-width.ts";
|
|
20
20
|
export { notificationBody };
|
|
21
21
|
export { visibleWidth } from "./text-width.ts";
|
|
@@ -729,21 +729,72 @@ export function customMessageText(content: unknown): string {
|
|
|
729
729
|
return String(content ?? "");
|
|
730
730
|
}
|
|
731
731
|
|
|
732
|
+
/** `Sep 11 12:03pm` — the time stamp on Claude Code's "Running scheduled task" line. */
|
|
733
|
+
export function formatFireTime(ms: number): string {
|
|
734
|
+
const d = new Date(ms);
|
|
735
|
+
const month = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][d.getMonth()];
|
|
736
|
+
const hours = d.getHours();
|
|
737
|
+
const h12 = hours % 12 === 0 ? 12 : hours % 12;
|
|
738
|
+
const minutes = String(d.getMinutes()).padStart(2, "0");
|
|
739
|
+
return `${month} ${d.getDate()} ${h12}:${minutes}${hours < 12 ? "am" : "pm"}`;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* A fired wakeup or loop tick in the transcript: Claude Code's dim
|
|
744
|
+
* `✻ Running scheduled task (Sep 11 12:03pm)` line, the prompt itself under it
|
|
745
|
+
* on ctrl+o (the prompt is the turn's input, verbatim — findings §21).
|
|
746
|
+
*/
|
|
747
|
+
export function scheduledTaskComponent(theme: ThemeLike, prompt: string, firedAt: number, expanded: boolean): TuiComponent {
|
|
748
|
+
const lines = prompt.trim().split("\n");
|
|
749
|
+
const headline = `Running scheduled task (${formatFireTime(firedAt)})`;
|
|
750
|
+
return linesComponent(() => {
|
|
751
|
+
if (!expanded) {
|
|
752
|
+
return [`${theme.fg("dim", "✻")} ${theme.fg("dim", headline)}${theme.fg("dim", ` (+${lines.length} lines, ${EXPAND_HINT})`)}`];
|
|
753
|
+
}
|
|
754
|
+
return [`${theme.fg("dim", "✻")} ${theme.fg("dim", headline)}`, ...lines.map((l) => ` ${theme.fg("muted", l)}`)];
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
732
758
|
/**
|
|
733
759
|
* Compact transcript rendering for harness-injected messages (background
|
|
734
|
-
* completions, subagent replies, monitor batches
|
|
735
|
-
* collapsed, the
|
|
760
|
+
* completions, subagent replies and reports, monitor batches): one dim headline
|
|
761
|
+
* per frame collapsed, each with its own "+N lines" hint, the bodies on ctrl+o.
|
|
762
|
+
* A coalesced message (several frames arriving together) therefore lists one
|
|
763
|
+
* line per event, the way the events would read had they arrived apart. The
|
|
764
|
+
* wire frames (`<task-notification>`, the agent-message envelope) are
|
|
765
|
+
* model-only; `parseNotificationFrames` takes them apart for display.
|
|
736
766
|
*/
|
|
737
767
|
export function notificationComponent(theme: ThemeLike, text: string, expanded: boolean): TuiComponent {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
const
|
|
768
|
+
// An agent's completion pointer says nothing the message above it does not;
|
|
769
|
+
// CC's transcript shows the message alone.
|
|
770
|
+
const parsed = parseNotificationFrames(text);
|
|
771
|
+
const frames = parsed.filter((frame) => !(frame.kind === "task" && frame.pointer));
|
|
772
|
+
if (frames.length === 0) frames.push({ kind: "text", text: framesToText(parsed) });
|
|
773
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
774
|
+
const muted = (s: string) => theme.fg("muted", s);
|
|
741
775
|
// pi's custom-message shell already prepends a spacer — emit content only.
|
|
742
|
-
return linesComponent(() => {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
776
|
+
return linesComponent((width) => {
|
|
777
|
+
const out: string[] = [];
|
|
778
|
+
// Body lines wrap to the terminal (CC wraps its long preamble line) rather
|
|
779
|
+
// than being cut; continuation lines start at column 0, as CC's do.
|
|
780
|
+
const body = (lines: string[]) => lines.flatMap((l) => wrapPlainText(` ${l}`, width).map((w) => muted(w)));
|
|
781
|
+
const hint = (lines: string[]) => (lines.length > 0 ? dim(` (+${lines.length} lines, ${EXPAND_HINT})`) : "");
|
|
782
|
+
for (const frame of frames) {
|
|
783
|
+
if (frame.kind === "agent-message") {
|
|
784
|
+
// Claude Code's look: `› Message from @ID (ctrl+o to expand)` collapsed;
|
|
785
|
+
// the header, then the body as delivered (preamble, indented report),
|
|
786
|
+
// indented under it, when expanded. The guard is never shown.
|
|
787
|
+
const bodyLines = frame.body.split("\n");
|
|
788
|
+
if (expanded) out.push(dim(`› Message from ${frame.from}`), ...body(bodyLines));
|
|
789
|
+
else out.push(dim(`› Message from @${frame.from}`) + hint(bodyLines));
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
const headline = frame.kind === "task" ? frame.summary : oneLine(frame.text.split("\n")[0] ?? "");
|
|
793
|
+
const bodyLines = frame.kind === "task" ? (frame.body ? frame.body.split("\n") : []) : frame.text.split("\n").slice(1);
|
|
794
|
+
const head = `${dim("✳")} ${muted(theme.italic(oneLine(headline)))}`;
|
|
795
|
+
if (expanded) out.push(head, ...body(bodyLines));
|
|
796
|
+
else out.push(head + hint(bodyLines));
|
|
746
797
|
}
|
|
747
|
-
return
|
|
798
|
+
return out;
|
|
748
799
|
});
|
|
749
800
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One Code's own update notice and the release facts behind it — shared by
|
|
3
|
+
* the bundled app (app/bin.mjs registers createUpdateCheck as an inline
|
|
4
|
+
* extension) and the doctor (doctor/update-lookup.ts reports the same
|
|
5
|
+
* "newest installable version"). Plain JS with no pi imports: the app loads
|
|
6
|
+
* it from the extension package it depends on, so it must run on bare Node.
|
|
7
|
+
*
|
|
8
|
+
* The notice replaces pi's (suppressed via PI_SKIP_VERSION_CHECK — its
|
|
9
|
+
* endpoint is hardcoded to pi's registry and cannot be pointed at one-code).
|
|
10
|
+
* It mirrors pi's behaviour: entirely non-blocking, and silent on any failure —
|
|
11
|
+
* an update hint must never cost startup time or surface a network error.
|
|
12
|
+
* Honours pi's offline switch (`--offline` sets PI_OFFLINE=1 before extensions
|
|
13
|
+
* load) and runs at most once a day per agent dir, stamped in
|
|
14
|
+
* `<agentDir>/last-update-check` (pi's own check is per session start; a
|
|
15
|
+
* daily cadence spares the registry on every TUI launch). pi's semver helpers
|
|
16
|
+
* are not exported from the package root, so the dotted-numeric compare lives
|
|
17
|
+
* here.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
|
|
22
|
+
export const APP_PACKAGE = "@one-ai/one-code";
|
|
23
|
+
export const EXTENSION_PACKAGE = "one-code-extension";
|
|
24
|
+
/** The Homebrew formula (IsuruMaduranga/homebrew-one-ai), renamed from one-code at 0.3.0. */
|
|
25
|
+
export const HOMEBREW_FORMULA = "onecode";
|
|
26
|
+
/** How each install method upgrades the app; the doctor and the update notice quote these. */
|
|
27
|
+
export const UPGRADE_COMMANDS = {
|
|
28
|
+
npm: `npm install -g ${APP_PACKAGE}`,
|
|
29
|
+
brew: `brew upgrade ${HOMEBREW_FORMULA}`,
|
|
30
|
+
"pi-package": "pi update",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The registry packument for a package (scoped slash percent-encoded). The
|
|
35
|
+
* full document, not `/latest`: its `time` map dates every version, which
|
|
36
|
+
* the Homebrew rule needs (pickAvailableVersion). ~14 KB for this package.
|
|
37
|
+
*/
|
|
38
|
+
export function registryPackumentUrl(name) {
|
|
39
|
+
return `https://registry.npmjs.org/${name.replace("/", "%2F")}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TIMEOUT_MS = 3000;
|
|
43
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
44
|
+
export const CHECK_INTERVAL_MS = DAY_MS;
|
|
45
|
+
/**
|
|
46
|
+
* Homebrew's `std_npm_args` passes npm `--min-release-age=1`, so a version
|
|
47
|
+
* becomes brew-installable only a day after its npm publish. A Homebrew user
|
|
48
|
+
* told about a fresher version would run `brew upgrade onecode` and get
|
|
49
|
+
* nothing, so every hint for that install method waits the same day.
|
|
50
|
+
*/
|
|
51
|
+
export const HOMEBREW_MIN_RELEASE_AGE_MS = DAY_MS;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The minimum release age for an install method: a day for Homebrew, none
|
|
55
|
+
* otherwise. `ONECODE_INSTALL_METHOD` is set by the app launcher (app/bin.mjs)
|
|
56
|
+
* from where the binary lives; a plain pi package never sets it.
|
|
57
|
+
*/
|
|
58
|
+
export function minReleaseAgeFor(env = process.env) {
|
|
59
|
+
return env.ONECODE_INSTALL_METHOD === "brew" ? HOMEBREW_MIN_RELEASE_AGE_MS : 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** pi's truthy-flag reading for PI_OFFLINE (1/true/yes). */
|
|
63
|
+
export function isOffline(env = process.env) {
|
|
64
|
+
return /^(1|true|yes)$/i.test(String(env.PI_OFFLINE ?? "").trim());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True when the stamp file records a check within the interval; unreadable/missing stamps count as due. */
|
|
68
|
+
export function checkedRecently(stampPath, now = Date.now()) {
|
|
69
|
+
if (!stampPath) return false;
|
|
70
|
+
try {
|
|
71
|
+
const last = Number(readFileSync(stampPath, "utf8").trim());
|
|
72
|
+
return Number.isFinite(last) && now - last >= 0 && now - last < CHECK_INTERVAL_MS;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** True when `candidate` is a strictly newer x.y.z than `current`. */
|
|
79
|
+
export function isNewerVersion(candidate, current) {
|
|
80
|
+
const parse = (v) => {
|
|
81
|
+
const parts = String(v).trim().split(".");
|
|
82
|
+
if (parts.length === 0 || parts.length > 3) return undefined;
|
|
83
|
+
const nums = parts.map((p) => (/^\d+$/.test(p) ? Number(p) : Number.NaN));
|
|
84
|
+
return nums.some(Number.isNaN) ? undefined : nums;
|
|
85
|
+
};
|
|
86
|
+
const a = parse(candidate);
|
|
87
|
+
const b = parse(current);
|
|
88
|
+
if (!a || !b) return false;
|
|
89
|
+
for (let i = 0; i < 3; i++) {
|
|
90
|
+
const diff = (a[i] ?? 0) - (b[i] ?? 0);
|
|
91
|
+
if (diff !== 0) return diff > 0;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The newest version in a registry packument worth announcing, or undefined.
|
|
98
|
+
* With no minimum age this is `dist-tags.latest` (what `npm install -g`
|
|
99
|
+
* fetches). With one, it is the newest plain x.y.z version whose `time` entry
|
|
100
|
+
* is at least that old — the newest one a `--min-release-age` install can see.
|
|
101
|
+
* Prerelease tags never qualify.
|
|
102
|
+
*/
|
|
103
|
+
export function pickAvailableVersion(packument, { minReleaseAgeMs = 0, now = Date.now() } = {}) {
|
|
104
|
+
if (!packument || typeof packument !== "object") return undefined;
|
|
105
|
+
const latest = packument["dist-tags"]?.latest;
|
|
106
|
+
if (minReleaseAgeMs <= 0) return typeof latest === "string" ? latest : undefined;
|
|
107
|
+
const time = packument.time;
|
|
108
|
+
if (!time || typeof time !== "object") return undefined;
|
|
109
|
+
let best;
|
|
110
|
+
for (const [version, published] of Object.entries(time)) {
|
|
111
|
+
if (version === "created" || version === "modified") continue;
|
|
112
|
+
const publishedAt = Date.parse(published);
|
|
113
|
+
if (!Number.isFinite(publishedAt) || now - publishedAt < minReleaseAgeMs) continue;
|
|
114
|
+
if (!/^\d+\.\d+\.\d+$/.test(version)) continue; // prereleases never qualify
|
|
115
|
+
if (best === undefined || isNewerVersion(version, best)) best = version;
|
|
116
|
+
}
|
|
117
|
+
return best;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function createUpdateCheck({ currentVersion, upgradeHint, stampPath, minReleaseAgeMs = 0 }) {
|
|
121
|
+
return function updateCheckExtension(pi) {
|
|
122
|
+
pi.on("session_start", (_event, ctx) => {
|
|
123
|
+
if (process.env.ONECODE_NO_UPDATE_CHECK === "1") return;
|
|
124
|
+
if (isOffline()) return;
|
|
125
|
+
if (!ctx.hasUI) return; // print/rpc runs stay clean for parsers
|
|
126
|
+
if (checkedRecently(stampPath)) return;
|
|
127
|
+
// Fire-and-forget: session_start handlers run serially before the
|
|
128
|
+
// prompt opens, so this must never be awaited.
|
|
129
|
+
void (async () => {
|
|
130
|
+
try {
|
|
131
|
+
// Stamp before the fetch: a hung registry must not re-fire on
|
|
132
|
+
// every /clear (session_start) within the day.
|
|
133
|
+
if (stampPath) {
|
|
134
|
+
try {
|
|
135
|
+
writeFileSync(stampPath, String(Date.now()));
|
|
136
|
+
} catch {
|
|
137
|
+
// Unwritable agent dir: check every start, as before.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const response = await fetch(registryPackumentUrl(APP_PACKAGE), { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
141
|
+
if (!response.ok) return;
|
|
142
|
+
const latest = pickAvailableVersion(await response.json(), { minReleaseAgeMs });
|
|
143
|
+
if (typeof latest === "string" && isNewerVersion(latest, currentVersion)) {
|
|
144
|
+
ctx.ui.notify(
|
|
145
|
+
`One Code ${latest} is available (you have ${currentVersion}). Upgrade: ${upgradeHint}`,
|
|
146
|
+
"info",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Offline, slow registry, unexpected payload: stay silent.
|
|
151
|
+
}
|
|
152
|
+
})();
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -22,7 +22,7 @@ export const USAGE_CHANNEL = "one-code:usage-recorded";
|
|
|
22
22
|
*/
|
|
23
23
|
export const USAGE_ENTRY_TYPE = "one-code:usage";
|
|
24
24
|
|
|
25
|
-
export type UsageSource = "subagent" | "classifier" | "reader" | "recap" | "setup";
|
|
25
|
+
export type UsageSource = "subagent" | "classifier" | "reader" | "recap" | "session-title" | "setup" | "btw";
|
|
26
26
|
|
|
27
27
|
export interface UsageRecord {
|
|
28
28
|
source: UsageSource;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal `which` (pure apart from the access check): the first PATH entry
|
|
3
|
+
* holding an executable of that name, with Windows `PATHEXT` honoured. Shared
|
|
4
|
+
* by the doctor's dependency checks and the shell resolvers
|
|
5
|
+
* (lib/shell-spawn.ts) so "is this on PATH" has one answer.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { accessSync, constants } from "node:fs";
|
|
9
|
+
import { delimiter, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export function whichOnPath(command: string, env: NodeJS.ProcessEnv, platform: string = process.platform): string | undefined {
|
|
12
|
+
if (!command) return undefined;
|
|
13
|
+
// Windows resolves `foo` to `foo.exe`/`foo.cmd` whether or not a directory is
|
|
14
|
+
// spelled (`node_modules/.bin/tsserver` is `…\tsserver.cmd` on disk), so the
|
|
15
|
+
// extension list applies to both forms; elsewhere only the literal name counts.
|
|
16
|
+
// The bare name comes LAST on Windows, and only when it already carries a
|
|
17
|
+
// PATHEXT extension: npm puts three shims per global next to each other
|
|
18
|
+
// (`tsserver`, a POSIX sh script for Git Bash; `tsserver.cmd`; `tsserver.ps1`)
|
|
19
|
+
// and the extensionless one is not a Windows executable — CreateProcess
|
|
20
|
+
// refuses it with ENOENT, which is what every npm-installed language server
|
|
21
|
+
// did on a real Windows machine until 2026-09-19 (findings §22).
|
|
22
|
+
const pathext = platform === "win32" ? (envVar(env, "PATHEXT") ?? ".EXE;.CMD;.BAT").split(";").filter(Boolean) : [];
|
|
23
|
+
const hasPathext = pathext.some((ext) => command.toLowerCase().endsWith(ext.toLowerCase()));
|
|
24
|
+
const extensions = platform === "win32" ? (hasPathext ? ["", ...pathext] : [...pathext, ""]) : [""];
|
|
25
|
+
if (command.includes("/") || command.includes("\\")) {
|
|
26
|
+
return extensions.map((ext) => command + ext).find(isExecutable);
|
|
27
|
+
}
|
|
28
|
+
for (const dir of (envVar(env, "PATH") ?? "").split(delimiter)) {
|
|
29
|
+
if (!dir) continue;
|
|
30
|
+
for (const ext of extensions) {
|
|
31
|
+
const candidate = join(dir, command + ext);
|
|
32
|
+
if (isExecutable(candidate)) return candidate;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read an environment variable by name regardless of the key's case. Windows
|
|
40
|
+
* spells it `Path`; `process.env` itself answers `env.PATH` there because it
|
|
41
|
+
* is a case-insensitive proxy, but a spread copy (`{ ...process.env, ...extra }`,
|
|
42
|
+
* the shape every spawn with its own variables builds) is a plain object and
|
|
43
|
+
* `copy.PATH` is undefined — so the lookup silently found nothing.
|
|
44
|
+
*/
|
|
45
|
+
export function envVar(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
|
46
|
+
const direct = env[name];
|
|
47
|
+
if (direct !== undefined) return direct;
|
|
48
|
+
const upper = name.toUpperCase();
|
|
49
|
+
for (const key of Object.keys(env)) {
|
|
50
|
+
if (key.toUpperCase() === upper) return env[key];
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isExecutable(path: string): boolean {
|
|
56
|
+
try {
|
|
57
|
+
accessSync(path, constants.X_OK);
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worktree session, announced over `pi.events` so other extensions can
|
|
3
|
+
* follow it without sharing module state (jiti isolation, findings §3). The
|
|
4
|
+
* worktree extension emits on every state change — enter, exit, and the
|
|
5
|
+
* session_start/session_tree restore; the footer shows the worktree's path
|
|
6
|
+
* and branch in place of the process cwd while one is active (Claude Code's
|
|
7
|
+
* footer does the same). The bus does not replay: a late subscriber sees the
|
|
8
|
+
* next change, which is fine here — the footer subscribes at load, and the
|
|
9
|
+
* first emit is the session_start restore.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const WORKTREE_CHANNEL = "one-code:worktree";
|
|
13
|
+
|
|
14
|
+
/** Payload: the active worktree, or null when the session left it. */
|
|
15
|
+
export interface WorktreeLocation {
|
|
16
|
+
path: string;
|
|
17
|
+
branch?: string;
|
|
18
|
+
}
|
|
@@ -130,7 +130,8 @@ export function worktreeBashWriteGuardReason(input: { command: string; cwd: stri
|
|
|
130
130
|
const home = input.home ?? homedir();
|
|
131
131
|
const evidence = analyzeShellCommand({ command: input.command, cwd: input.cwd, home });
|
|
132
132
|
for (const write of evidence.writes) {
|
|
133
|
-
|
|
133
|
+
// The target as the shell reaches it (Git Bash spellings converted), judged like a file-tool path.
|
|
134
|
+
const mapped = sharedCheckoutWriteTarget(write.absolute, input.cwd, input.isolation, home);
|
|
134
135
|
if (mapped) return refusal(input.isolation, `this command writes to ${write.token}, which`, mapped);
|
|
135
136
|
}
|
|
136
137
|
return undefined;
|
package/extensions/lsp/client.ts
CHANGED
|
@@ -19,6 +19,8 @@ import { type ChildProcess, spawn } from "node:child_process";
|
|
|
19
19
|
import { readFileSync } from "node:fs";
|
|
20
20
|
import { pathToFileURL } from "node:url";
|
|
21
21
|
import type { LspDiagnostic } from "./format.ts";
|
|
22
|
+
import { commandLaunch } from "../lib/command-launch.ts";
|
|
23
|
+
import { killProcessTree } from "../lib/process-tree.ts";
|
|
22
24
|
import { createReaderState, encodeMessage, type JsonRpcMessage, readMessages } from "./protocol.ts";
|
|
23
25
|
|
|
24
26
|
const INITIALIZE_TIMEOUT_MS = 15_000;
|
|
@@ -99,10 +101,16 @@ export class LspClient {
|
|
|
99
101
|
async start(): Promise<void> {
|
|
100
102
|
if (this.child) return;
|
|
101
103
|
|
|
102
|
-
const
|
|
104
|
+
const env = this.options.env ? { ...process.env, ...this.options.env } : process.env;
|
|
105
|
+
// On Windows an npm-installed server is a .cmd shim, which a bare spawn
|
|
106
|
+
// cannot start (lib/command-launch.ts); failure messages keep naming the command.
|
|
107
|
+
const launch = commandLaunch(this.config.command, this.config.args, env);
|
|
108
|
+
const child = spawn(launch.command, launch.args, {
|
|
103
109
|
cwd: this.root,
|
|
104
110
|
stdio: ["pipe", "pipe", "pipe"],
|
|
105
|
-
env
|
|
111
|
+
env,
|
|
112
|
+
windowsHide: true,
|
|
113
|
+
windowsVerbatimArguments: launch.windowsVerbatimArguments,
|
|
106
114
|
});
|
|
107
115
|
this.child = child;
|
|
108
116
|
|
|
@@ -322,7 +330,10 @@ export class LspClient {
|
|
|
322
330
|
this.child = undefined;
|
|
323
331
|
await new Promise<void>((resolve) => {
|
|
324
332
|
const timer = setTimeout(() => {
|
|
325
|
-
|
|
333
|
+
// The tree, not the leader: on Windows a .cmd-shimmed server runs
|
|
334
|
+
// under the cmd.exe `commandLaunch` started, and killing cmd.exe
|
|
335
|
+
// alone would orphan the server on the inherited pipes.
|
|
336
|
+
killProcessTree(child, "SIGKILL");
|
|
326
337
|
resolve();
|
|
327
338
|
}, 1000);
|
|
328
339
|
timer.unref?.();
|
package/extensions/lsp/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
|
|
29
29
|
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
30
30
|
import { Type } from "typebox";
|
|
31
31
|
import { DEFER_CHANNEL } from "../lib/deferred.ts";
|
|
32
|
+
import { absoluteFrom } from "../lib/paths.ts";
|
|
32
33
|
import { defaultDiscoverRoots, discoverPlugins, pluginResources } from "../lib/plugins.ts";
|
|
33
34
|
import { REMINDER_CHANNEL, type ReminderPayload } from "../lib/reminders.ts";
|
|
34
35
|
import { ccToolRenderers, cutPlainText } from "../lib/tui-render.ts";
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
} from "./plugin-servers.ts";
|
|
46
47
|
import { findProjectRoot, serverForPath, typescriptPreflight } from "./servers.ts";
|
|
47
48
|
import { computeDelta, DeliveredTracker, fingerprintDiagnostic, formatNewDiagnostics, markDelivered } from "./watcher.ts";
|
|
49
|
+
import { registerLocalCommand } from "../lib/local-command.ts";
|
|
48
50
|
|
|
49
51
|
/** Everything needed to spawn/reuse the server responsible for a path. */
|
|
50
52
|
interface ResolvedTarget {
|
|
@@ -254,8 +256,13 @@ export default function lspExtension(pi: ExtensionAPI) {
|
|
|
254
256
|
// sync the file, await the server's next publish, and reset the file's
|
|
255
257
|
// delivered-set so a reintroduced issue resurfaces.
|
|
256
258
|
if (!event.isError && (event.toolName === "edit" || event.toolName === "write")) {
|
|
257
|
-
const
|
|
258
|
-
if (typeof
|
|
259
|
+
const raw = (event.input as { path?: unknown }).path;
|
|
260
|
+
if (typeof raw === "string") {
|
|
261
|
+
// One absolute spelling for everything below: the server key (root),
|
|
262
|
+
// the delivered-set URI and the diagnostics request. A relative path
|
|
263
|
+
// used to yield root `.` and a URI keyed on process.cwd(), so one file
|
|
264
|
+
// could end up on a second server instance (findings §23).
|
|
265
|
+
const path = absoluteFrom(ctx.cwd, raw);
|
|
259
266
|
const target = resolveTarget(path, ctx.cwd);
|
|
260
267
|
reportRoutingIssuesOnce(ctx);
|
|
261
268
|
if (target) {
|
|
@@ -309,7 +316,7 @@ export default function lspExtension(pi: ExtensionAPI) {
|
|
|
309
316
|
),
|
|
310
317
|
}),
|
|
311
318
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
312
|
-
const path =
|
|
319
|
+
const path = absoluteFrom(ctx.cwd, params.path);
|
|
313
320
|
const target = resolveTarget(path, ctx.cwd);
|
|
314
321
|
// One keep-alive spans both awaits (same pattern as the tool_result hook).
|
|
315
322
|
const fetched = target
|
|
@@ -355,9 +362,9 @@ export default function lspExtension(pi: ExtensionAPI) {
|
|
|
355
362
|
},
|
|
356
363
|
});
|
|
357
364
|
|
|
358
|
-
pi
|
|
365
|
+
registerLocalCommand(pi, "lsp", {
|
|
359
366
|
description: "Show language server status",
|
|
360
|
-
handler: async (
|
|
367
|
+
handler: async (args, ctx) => {
|
|
361
368
|
const lines = [...clients.entries()].map(
|
|
362
369
|
([key, client]) => `${client.isRunning ? "running" : "stopped"} ${key} (${client.diagnosticsCount} diagnostics)`,
|
|
363
370
|
);
|
|
@@ -11,13 +11,43 @@
|
|
|
11
11
|
* one-time warning, /lsp, and the lsp_diagnostics tool all show the same text.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { isAbsolute } from "node:path";
|
|
15
|
+
import { TYPESCRIPT_PROJECT_INSTALL } from "./servers.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Tools that come from the OS package manager, by platform. Homebrew is a
|
|
19
|
+
* macOS answer; a Windows user needs winget (the doctor said `brew install
|
|
20
|
+
* ripgrep` on Windows until 2026-09-19), a Linux user their distro's manager.
|
|
21
|
+
*/
|
|
22
|
+
const PACKAGE_MANAGER_HINTS: Record<string, { darwin: string; win32: string; linux: string }> = {
|
|
23
|
+
ripgrep: { darwin: "brew install ripgrep", win32: "winget install BurntSushi.ripgrep.MSVC", linux: "apt install ripgrep (or your distribution's package manager)" },
|
|
24
|
+
jdtls: { darwin: "brew install jdtls", win32: "download from https://download.eclipse.org/jdtls/ and put its bin\\jdtls.bat on PATH", linux: "download from https://download.eclipse.org/jdtls/ and put its bin/jdtls on PATH" },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The install command for a package-manager tool on `platform` (defaults to this one). */
|
|
28
|
+
export function installHint(tool: string, platform: string = process.platform): string {
|
|
29
|
+
const byPlatform = PACKAGE_MANAGER_HINTS[tool];
|
|
30
|
+
if (!byPlatform) return `install ${tool} and make sure it is on your PATH`;
|
|
31
|
+
return platform === "win32" ? byPlatform.win32 : platform === "darwin" ? byPlatform.darwin : byPlatform.linux;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The install command for a built-in server on `platform`: the package-manager
|
|
36
|
+
* ones (jdtls) per platform, the rest as in `INSTALL_HINTS`. The doctor
|
|
37
|
+
* renders a report for an injected platform, so it must not read the
|
|
38
|
+
* module-load default frozen into `INSTALL_HINTS`.
|
|
39
|
+
*/
|
|
40
|
+
export function serverInstallHint(command: string, platform: string = process.platform): string | undefined {
|
|
41
|
+
return PACKAGE_MANAGER_HINTS[command] ? installHint(command, platform) : INSTALL_HINTS[command];
|
|
42
|
+
}
|
|
43
|
+
|
|
14
44
|
/** Install commands for the built-in table's servers (and their npm siblings). */
|
|
15
45
|
export const INSTALL_HINTS: Record<string, string> = {
|
|
16
|
-
"typescript-language-server":
|
|
46
|
+
"typescript-language-server": `npm install -g typescript-language-server (and in the project: ${TYPESCRIPT_PROJECT_INSTALL} — the server never uses a global TypeScript)`,
|
|
17
47
|
"pyright-langserver": "npm install -g pyright",
|
|
18
48
|
gopls: "go install golang.org/x/tools/gopls@latest",
|
|
19
49
|
"rust-analyzer": "rustup component add rust-analyzer",
|
|
20
|
-
jdtls: "
|
|
50
|
+
jdtls: installHint("jdtls"),
|
|
21
51
|
};
|
|
22
52
|
|
|
23
53
|
/** True when the failure is a spawn ENOENT — the binary itself is absent. */
|
|
@@ -35,7 +65,7 @@ export function describeStartFailure(rawFailure: string, command: string, plugin
|
|
|
35
65
|
const from = pluginName ? ` (configured by the ${pluginName} plugin)` : "";
|
|
36
66
|
const hint = INSTALL_HINTS[command];
|
|
37
67
|
if (hint) return `${command} is not installed${from}. Install it with: ${hint}`;
|
|
38
|
-
if (command
|
|
68
|
+
if (isAbsolute(command)) {
|
|
39
69
|
return `${command} does not exist${from}. ${pluginName ? "Check the plugin's installation." : "Check the configured path."}`;
|
|
40
70
|
}
|
|
41
71
|
const docs = pluginName ? " (the plugin's documentation should say how)" : "";
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { readFileSync } from "node:fs";
|
|
26
|
-
import { extname, join, resolve } from "node:path";
|
|
26
|
+
import { extname, isAbsolute, join, resolve } from "node:path";
|
|
27
27
|
import { pathWithinBase, sanitizePathSegment } from "../lib/plugin-root.ts";
|
|
28
28
|
|
|
29
29
|
export interface ResolvedPluginServer {
|
|
@@ -198,7 +198,7 @@ export function resolveServerConfig(
|
|
|
198
198
|
if (typeof config.command !== "string" || config.command.length === 0) {
|
|
199
199
|
return reject('"command" must be a non-empty string');
|
|
200
200
|
}
|
|
201
|
-
if (config.command.includes(" ") && !config.command
|
|
201
|
+
if (config.command.includes(" ") && !isAbsolute(config.command)) {
|
|
202
202
|
return reject('"command" must not contain spaces — use the args array for arguments');
|
|
203
203
|
}
|
|
204
204
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { existsSync } from "node:fs";
|
|
9
9
|
import { dirname, extname, join } from "node:path";
|
|
10
|
+
import { ancestorDirs } from "../lib/claude-context.ts";
|
|
10
11
|
|
|
11
12
|
export interface ServerConfig {
|
|
12
13
|
command: string;
|
|
@@ -67,13 +68,34 @@ export function findProjectRoot(startPath: string, markers: string[], fallback:
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
/**
|
|
70
|
-
* TypeScript
|
|
71
|
-
* typescript
|
|
72
|
-
*
|
|
71
|
+
* The TypeScript install typescript-language-server will use for `root`: the
|
|
72
|
+
* nearest `node_modules/typescript` from the project root upward (the server
|
|
73
|
+
* resolves `typescript` the way Node does, so a monorepo's root install
|
|
74
|
+
* counts), or undefined when there is none. A global `npm i -g typescript`
|
|
75
|
+
* is never found — npm does not put global siblings on each other's
|
|
76
|
+
* resolution path (verified 2026-09-19 on macOS and Ubuntu, findings §23).
|
|
77
|
+
*/
|
|
78
|
+
export function findTypescript(root: string): string | undefined {
|
|
79
|
+
return ancestorDirs(root)
|
|
80
|
+
.reverse()
|
|
81
|
+
.map((dir) => join(dir, "node_modules", "typescript"))
|
|
82
|
+
.find((candidate) => existsSync(candidate));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The one way to give typescript-language-server a TypeScript it will use: 5.x in the project (findings §23). */
|
|
86
|
+
export const TYPESCRIPT_PROJECT_INSTALL = "npm install -D typescript@5";
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Say up front why typescript-language-server cannot analyse `root`, instead
|
|
90
|
+
* of surfacing the server's own initialize failure: no TypeScript reachable
|
|
91
|
+
* from the project (the server does not use a global one), or a TypeScript 7
|
|
92
|
+
* whose native compiler dropped the `lib/tsserver.js` the server requires.
|
|
73
93
|
*/
|
|
74
94
|
export function typescriptPreflight(root: string): string | undefined {
|
|
75
|
-
const
|
|
76
|
-
if (!
|
|
77
|
-
|
|
78
|
-
|
|
95
|
+
const typescript = findTypescript(root);
|
|
96
|
+
if (!typescript) {
|
|
97
|
+
return `No TypeScript installation is reachable from this project (typescript-language-server resolves \`typescript\` from the project's node_modules upward and never uses a global install). Run \`${TYPESCRIPT_PROJECT_INSTALL}\` in the project for LSP diagnostics.`;
|
|
98
|
+
}
|
|
99
|
+
if (existsSync(join(typescript, "lib", "tsserver.js"))) return undefined;
|
|
100
|
+
return `The project's TypeScript has no lib/tsserver.js (TypeScript 7's native compiler removed it), so typescript-language-server cannot analyse this project. Run \`${TYPESCRIPT_PROJECT_INSTALL}\` for LSP diagnostics.`;
|
|
79
101
|
}
|