one-code-extension 0.3.1 → 0.4.1

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 (113) hide show
  1. package/README.md +214 -147
  2. package/docs/guide/README.md +1 -0
  3. package/docs/guide/configuration.md +2 -2
  4. package/docs/guide/differences-from-claude-code.md +13 -4
  5. package/docs/guide/hooks.md +14 -2
  6. package/docs/guide/installation.md +54 -6
  7. package/docs/guide/permissions-modes-and-auto-mode.md +23 -7
  8. package/docs/guide/reference.md +5 -2
  9. package/docs/guide/sessions-and-context.md +18 -0
  10. package/docs/guide/terminal-interface.md +1 -1
  11. package/docs/guide/tools.md +24 -2
  12. package/docs/guide/troubleshooting.md +14 -4
  13. package/docs/guide/windows.md +189 -0
  14. package/extensions/auto-mode/actions.ts +3 -1
  15. package/extensions/auto-mode/paths.ts +21 -23
  16. package/extensions/auto-mode/safety-floor.ts +44 -6
  17. package/extensions/auto-mode/shell-analysis.ts +25 -10
  18. package/extensions/auto-mode/transcript.ts +15 -5
  19. package/extensions/background/index.ts +83 -34
  20. package/extensions/background/monitor-batch.ts +7 -5
  21. package/extensions/background/wakeup.ts +18 -24
  22. package/extensions/bash/background.ts +19 -10
  23. package/extensions/bash/guards.ts +36 -16
  24. package/extensions/bash/index.ts +36 -166
  25. package/extensions/branding/index.ts +35 -4
  26. package/extensions/btw/index.ts +207 -0
  27. package/extensions/btw/panel.ts +179 -0
  28. package/extensions/btw/prompt.ts +45 -0
  29. package/extensions/clear/index.ts +15 -0
  30. package/extensions/compaction/fit.ts +9 -6
  31. package/extensions/doctor/build.ts +22 -2
  32. package/extensions/doctor/dependencies.ts +63 -31
  33. package/extensions/doctor/fix-prompt.ts +12 -4
  34. package/extensions/doctor/index.ts +1 -0
  35. package/extensions/doctor/models.ts +2 -1
  36. package/extensions/doctor/report.ts +2 -3
  37. package/extensions/doctor/update-lookup.ts +19 -11
  38. package/extensions/effort/index.ts +2 -1
  39. package/extensions/footer/footer-line.ts +19 -0
  40. package/extensions/footer/index.ts +25 -5
  41. package/extensions/hooks/executor.ts +84 -18
  42. package/extensions/hooks/index.ts +1 -0
  43. package/extensions/hooks/matcher.ts +23 -9
  44. package/extensions/hooks/settings.ts +15 -0
  45. package/extensions/lib/branch-restore.ts +12 -0
  46. package/extensions/lib/claude-context.ts +2 -3
  47. package/extensions/lib/claude-settings.ts +19 -0
  48. package/extensions/lib/command-launch.ts +71 -0
  49. package/extensions/lib/deferred.ts +173 -33
  50. package/extensions/lib/git.ts +16 -0
  51. package/extensions/lib/keys.ts +38 -0
  52. package/extensions/lib/local-command.ts +112 -0
  53. package/extensions/lib/model-facts.generated.json +263 -68
  54. package/extensions/lib/model-policy.ts +13 -0
  55. package/extensions/lib/model-tier.ts +16 -5
  56. package/extensions/lib/notifications.ts +547 -53
  57. package/extensions/lib/paths.ts +130 -2
  58. package/extensions/lib/pi-ai-estimate.ts +57 -26
  59. package/extensions/lib/pi-version.ts +1 -1
  60. package/extensions/lib/process-tree.ts +131 -5
  61. package/extensions/lib/reminders.ts +80 -27
  62. package/extensions/lib/scratchpad.ts +11 -10
  63. package/extensions/lib/shell-spawn.ts +298 -0
  64. package/extensions/lib/shell-tool.ts +252 -0
  65. package/extensions/lib/shell-tools.ts +13 -0
  66. package/extensions/lib/side-call.ts +76 -0
  67. package/extensions/lib/tui-render.ts +62 -11
  68. package/extensions/lib/update-check.mjs +155 -0
  69. package/extensions/lib/usage-bus.ts +1 -1
  70. package/extensions/lib/which.ts +62 -0
  71. package/extensions/lib/worktree-channel.ts +18 -0
  72. package/extensions/lib/worktree-isolation.ts +2 -1
  73. package/extensions/lsp/client.ts +14 -3
  74. package/extensions/lsp/index.ts +12 -5
  75. package/extensions/lsp/install-hints.ts +33 -3
  76. package/extensions/lsp/plugin-servers.ts +2 -2
  77. package/extensions/lsp/servers.ts +29 -7
  78. package/extensions/mcp/index.ts +3 -2
  79. package/extensions/memory/entries.ts +9 -6
  80. package/extensions/memory/index.ts +3 -2
  81. package/extensions/memory/open-external.ts +4 -1
  82. package/extensions/model-default/index.ts +21 -2
  83. package/extensions/permissions/index.ts +57 -28
  84. package/extensions/permissions/matcher.ts +120 -33
  85. package/extensions/permissions/modes.ts +14 -6
  86. package/extensions/permissions/powershell-rules.ts +549 -0
  87. package/extensions/permissions/session-grant.ts +9 -9
  88. package/extensions/plugins/index.ts +3 -2
  89. package/extensions/plugins/install/install.ts +1 -1
  90. package/extensions/powershell/description.ts +139 -0
  91. package/extensions/powershell/guards.ts +91 -0
  92. package/extensions/powershell/index.ts +89 -0
  93. package/extensions/powershell/policy.ts +91 -0
  94. package/extensions/recap/index.ts +10 -24
  95. package/extensions/recap/prompt.ts +3 -3
  96. package/extensions/session-title/index.ts +120 -0
  97. package/extensions/session-title/title.ts +111 -0
  98. package/extensions/skill/index.ts +40 -18
  99. package/extensions/skill/invoke.ts +1 -0
  100. package/extensions/subagents/hand-back-review.ts +10 -12
  101. package/extensions/subagents/index.ts +221 -78
  102. package/extensions/subagents/model-select.ts +75 -7
  103. package/extensions/system-prompt/environment.ts +14 -1
  104. package/extensions/system-reminder/index.ts +9 -0
  105. package/extensions/tasks/index.ts +2 -1
  106. package/extensions/tool-search/index.ts +41 -20
  107. package/extensions/workflow/agent-session.ts +6 -4
  108. package/extensions/workflow/index.ts +28 -5
  109. package/extensions/worktree/guards.ts +5 -4
  110. package/extensions/worktree/index.ts +15 -2
  111. package/extensions/worktree/rewrite.ts +17 -0
  112. package/package.json +9 -5
  113. 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, wakeups): one dim headline
735
- * collapsed, the full body on ctrl+o.
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
- const body = notificationBody(text);
739
- const lines = body.split("\n");
740
- const headline = oneLine(lines[0] ?? "");
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
- if (!expanded) {
744
- const more = lines.length > 1 ? theme.fg("dim", ` (+${lines.length - 1} lines, ${EXPAND_HINT})`) : "";
745
- return [`${theme.fg("dim", "✳")} ${theme.fg("muted", theme.italic(headline))}${more}`];
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 [`${theme.fg("dim", "✳")} ${theme.fg("muted", theme.italic(headline))}`, ...lines.slice(1).map((l) => ` ${theme.fg("muted", l)}`)];
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
- const mapped = sharedCheckoutWriteTarget(write.token, input.cwd, input.isolation, home);
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;
@@ -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 child = spawn(this.config.command, this.config.args, {
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: this.options.env ? { ...process.env, ...this.options.env } : process.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
- child.kill("SIGKILL");
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?.();
@@ -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 path = (event.input as { path?: unknown }).path;
258
- if (typeof path === "string") {
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 = params.path.startsWith("/") ? params.path : `${ctx.cwd}/${params.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.registerCommand("lsp", {
365
+ registerLocalCommand(pi, "lsp", {
359
366
  description: "Show language server status",
360
- handler: async (_args, ctx) => {
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": "npm install -g typescript-language-server typescript",
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: "brew install 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.startsWith("/")) {
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.startsWith("/")) {
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 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.
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 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.";
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
  }