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,179 @@
1
+ /**
2
+ * Pure state, key decoding, and rendering for the `/btw` answer overlay — Claude
3
+ * Code's side-question panel: a focused, scrollable view of the answer with the
4
+ * question dim above it. `↑/↓` (and PgUp/PgDn) scroll, `c` copies the answer,
5
+ * `Esc` closes.
6
+ *
7
+ * Kept free of pi imports so the scroll maths and layout are unit-tested; the
8
+ * extension owns the overlay, repaint, and the actual clipboard write. CC's
9
+ * panel also offers `f to fork`; One Code omits it (pi exposes no primitive to
10
+ * branch a session with an injected exchange) — docs/decisions/btw.md.
11
+ */
12
+
13
+ import { panelTopRule, safeThemePaint, truncateLine, wrapPlainText } from "../lib/tui-render.ts";
14
+
15
+ export const BTW_MAX_HEIGHT = 24;
16
+
17
+ /** Key hint shown at the foot of the panel. Every key here is discoverable. */
18
+ export const BTW_HINT = "↑/↓ to scroll · c to copy · Esc to close";
19
+
20
+ /** Most echoed question lines shown in the header, so it can't starve the body. */
21
+ export const MAX_QUESTION_LINES = 4;
22
+
23
+ /**
24
+ * The panel's body state. The pure module owns the text for each phase so every
25
+ * appearance (loading, error, answer) is rendered — and tested — here rather
26
+ * than assembled by the extension wiring.
27
+ */
28
+ export type BtwBody = { kind: "loading" } | { kind: "error"; message: string } | { kind: "answer"; text: string };
29
+
30
+ /** The text shown in the body for a given phase. */
31
+ function bodyText(body: BtwBody): string {
32
+ switch (body.kind) {
33
+ case "loading":
34
+ return "Thinking…";
35
+ case "error":
36
+ return `Side question failed: ${body.message}`;
37
+ case "answer":
38
+ return body.text || "(no answer)";
39
+ }
40
+ }
41
+
42
+ export interface BtwPanelState {
43
+ /** First body line shown (0 = top). */
44
+ offset: number;
45
+ /** Largest valid offset for the current viewport; render keeps it current. */
46
+ maxOffset: number;
47
+ /** Visible body lines in the current viewport; render keeps it current. */
48
+ viewport: number;
49
+ /** Set for one frame after a copy so the footer can confirm it. */
50
+ copied: boolean;
51
+ }
52
+
53
+ export function initialBtwState(): BtwPanelState {
54
+ return { offset: 0, maxOffset: 0, viewport: 1, copied: false };
55
+ }
56
+
57
+ export type BtwKey = { kind: "up" | "down" | "pageUp" | "pageDown" | "top" | "bottom" | "copy" | "close" };
58
+
59
+ export function decodeBtwKey(data: string): BtwKey | undefined {
60
+ switch (data) {
61
+ case "\x1b[A":
62
+ case "\x1bOA":
63
+ case "\x10": // ctrl+p
64
+ return { kind: "up" };
65
+ case "\x1b[B":
66
+ case "\x1bOB":
67
+ case "\x0e": // ctrl+n
68
+ return { kind: "down" };
69
+ case "\x1b[5~":
70
+ return { kind: "pageUp" };
71
+ case "\x1b[6~":
72
+ return { kind: "pageDown" };
73
+ case "\x1b[H":
74
+ case "\x1bOH":
75
+ return { kind: "top" };
76
+ case "\x1b[F":
77
+ case "\x1bOF":
78
+ return { kind: "bottom" };
79
+ case "c":
80
+ case "C":
81
+ return { kind: "copy" };
82
+ case "\x1b":
83
+ case "\x03": // ctrl+c
84
+ case "q":
85
+ return { kind: "close" };
86
+ default:
87
+ return undefined;
88
+ }
89
+ }
90
+
91
+ export type BtwEffect = { kind: "copy" } | { kind: "close" };
92
+
93
+ /** Page size for PgUp/PgDn: nearly a full viewport, one line of overlap for orientation. */
94
+ function pageStep(viewport: number): number {
95
+ return Math.max(1, viewport - 1);
96
+ }
97
+
98
+ /**
99
+ * Apply a key to the scroll state. Returns an effect for the extension to run
100
+ * (copy, close) or undefined for a scroll (repaint only). Offsets are clamped to
101
+ * `state.maxOffset`, and pages by `state.viewport` — both set by the most recent
102
+ * render from the real viewport.
103
+ */
104
+ export function applyBtwKey(state: BtwPanelState, key: BtwKey): BtwEffect | undefined {
105
+ state.copied = false;
106
+ const step = pageStep(state.viewport);
107
+ switch (key.kind) {
108
+ case "up":
109
+ state.offset = Math.max(0, state.offset - 1);
110
+ return undefined;
111
+ case "down":
112
+ state.offset = Math.min(state.maxOffset, state.offset + 1);
113
+ return undefined;
114
+ case "pageUp":
115
+ state.offset = Math.max(0, state.offset - step);
116
+ return undefined;
117
+ case "pageDown":
118
+ state.offset = Math.min(state.maxOffset, state.offset + step);
119
+ return undefined;
120
+ case "top":
121
+ state.offset = 0;
122
+ return undefined;
123
+ case "bottom":
124
+ state.offset = state.maxOffset;
125
+ return undefined;
126
+ case "copy":
127
+ return { kind: "copy" };
128
+ case "close":
129
+ return { kind: "close" };
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Render the panel to at most `height` lines. The header (the dim question) and
135
+ * footer (rule + key hint, with a scroll indicator and a copy confirmation) are
136
+ * fixed; the answer scrolls within what remains. Clamps and writes back
137
+ * `state.offset`/`state.maxOffset` so key handling scrolls against the real
138
+ * body length. Every line is width-truncated (pi-tui crashes on an overwide
139
+ * line).
140
+ */
141
+ export function renderBtwPanel(
142
+ input: { state: BtwPanelState; question: string; body: BtwBody; width: number; height: number },
143
+ theme?: unknown,
144
+ ): string[] {
145
+ const { state, question, body, width, height } = input;
146
+ const paint = safeThemePaint(theme);
147
+ const inner = Math.max(1, width);
148
+
149
+ // Cap the echoed question so a long one (a pasted paragraph or URL) cannot
150
+ // grow the header past the panel height and starve the answer of rows; a
151
+ // truncated last line gets an ellipsis. The answer is what scrolls, not the
152
+ // question.
153
+ const questionLines = wrapPlainText(question.trim(), inner);
154
+ const shownQuestion =
155
+ questionLines.length > MAX_QUESTION_LINES
156
+ ? [...questionLines.slice(0, MAX_QUESTION_LINES - 1), truncateLine(`${questionLines[MAX_QUESTION_LINES - 1]}…`, inner)]
157
+ : questionLines;
158
+ const header = [
159
+ paint("accent", truncateLine("Side question", inner)),
160
+ ...shownQuestion.map((line) => paint("muted", truncateLine(line, inner))),
161
+ panelTopRule(paint, inner),
162
+ ];
163
+
164
+ const bodyAll = wrapPlainText(bodyText(body), inner);
165
+ const capacity = Math.max(1, height - header.length - 2); // 2 = footer rule + hint
166
+ state.viewport = capacity;
167
+ state.maxOffset = Math.max(0, bodyAll.length - capacity);
168
+ state.offset = Math.min(Math.max(0, state.offset), state.maxOffset);
169
+
170
+ const visible = bodyAll.slice(state.offset, state.offset + capacity).map((line) => truncateLine(line, inner));
171
+ // Pad a short answer so the footer sits at the panel's foot, not mid-screen.
172
+ while (visible.length < capacity) visible.push("");
173
+
174
+ const scrollNote = state.maxOffset > 0 ? ` (${state.offset + 1}-${Math.min(state.offset + capacity, bodyAll.length)} of ${bodyAll.length})` : "";
175
+ const hint = state.copied ? "Copied to clipboard" : `${BTW_HINT}${scrollNote}`;
176
+ const footer = [panelTopRule(paint, inner), paint("muted", truncateLine(hint, inner))];
177
+
178
+ return [...header, ...visible, ...footer];
179
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Pure pieces of Claude Code's `/btw` ("by the way") side-question feature: a
3
+ * lightweight, one-off question answered against the current conversation's
4
+ * context while the main agent keeps working. Prompt text is verbatim from CC's
5
+ * capture (captures/btw.json, CC 2.1.278): a `<system-reminder>` that frames the
6
+ * answering model as a separate, tool-less instance, prepended to the user's
7
+ * question as the final user message.
8
+ *
9
+ * Kept free of pi imports so the message assembly and text extraction are
10
+ * unit-tested; the extension owns the model call, the spinner, and the panel.
11
+ */
12
+
13
+ /**
14
+ * CC's verbatim side-question reminder (captures/btw.json message 20). It frames
15
+ * the answering instance as a separate, lightweight, tool-less agent so the
16
+ * model answers in one shot from context instead of trying to act. One Code
17
+ * backs this framing for real: it sends name-only tool stubs (never live tools)
18
+ * and does not persist the exchange, so there genuinely is no follow-up turn.
19
+ */
20
+ export const SIDE_QUESTION_REMINDER = `<system-reminder>This is a side question from the user. You must answer this question directly in a single response.
21
+
22
+ IMPORTANT CONTEXT:
23
+ - You are a separate, lightweight agent spawned to answer this one question
24
+ - The main agent is NOT interrupted - it continues working independently in the background
25
+ - You share the conversation context but are a completely separate instance
26
+ - Do NOT reference being interrupted or what you were "previously doing" - that framing is incorrect
27
+
28
+ CRITICAL CONSTRAINTS:
29
+ - You have NO tools available - you cannot read files, run commands, search, or take any actions
30
+ - Do NOT write tool calls or tool output as text (for example invoke or function_calls XML blocks) - nothing you write here is executed; if answering would need reading files, running commands, or searching, say that can't be checked from a side question and suggest asking in the main conversation
31
+ - This is a one-off response - there will be no follow-up turns
32
+ - You can ONLY provide information based on what you already know from the conversation context
33
+ - NEVER say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action
34
+ - If you don't know the answer, say so - do not offer to look it up or investigate
35
+
36
+ Simply answer the question with the information you have.</system-reminder>`;
37
+
38
+ /**
39
+ * The final user message a `/btw` call sends: CC's reminder, a blank line, then
40
+ * the user's question verbatim (captures/btw.json message 20 is exactly this
41
+ * string). The question is trimmed of surrounding whitespace only.
42
+ */
43
+ export function sideQuestionMessage(question: string): string {
44
+ return `${SIDE_QUESTION_REMINDER}\n\n${question.trim()}`;
45
+ }
@@ -13,9 +13,20 @@
13
13
  * a clean context — which is what a `/clear` habit wants. Like `/exit`→`/quit`,
14
14
  * we cannot fold this into pi's `/new` palette entry (pi has no command
15
15
  * aliases), so `/clear` and `/new` are two entries that both reset.
16
+ *
17
+ * Claude Code tells the model the conversation was cleared: the new
18
+ * conversation opens with the `/clear` breadcrumb (caveat, command, empty
19
+ * stdout — lib/local-command.ts). It cannot be queued from the handler: pi's
20
+ * `newSession()` replaces the whole runtime (a `session_shutdown` with reason
21
+ * "new" on the old extension runner, every factory run again — findings §8),
22
+ * so a reminder queued before the call dies with the old queue. The NEW
23
+ * instance announces it instead, from its `session_start` with reason "new" —
24
+ * which is also what pi's own `/new` and an RPC `new_session` produce, and
25
+ * they are the same user action as far as the model is concerned.
16
26
  */
17
27
 
18
28
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
+ import { announceLocalCommand } from "../lib/local-command.ts";
19
30
 
20
31
  export default function clearExtension(pi: ExtensionAPI) {
21
32
  pi.registerCommand("clear", {
@@ -24,4 +35,8 @@ export default function clearExtension(pi: ExtensionAPI) {
24
35
  await ctx.newSession();
25
36
  },
26
37
  });
38
+
39
+ pi.on("session_start", (event) => {
40
+ if ((event as { reason?: string }).reason === "new") announceLocalCommand(pi, { name: "clear" });
41
+ });
27
42
  }
@@ -27,16 +27,19 @@
27
27
  *
28
28
  * Both decisions ask pi-ai's `clampMaxTokensToContext` — the very function
29
29
  * `completeSimple` applies to the request — rather than re-deriving its
30
- * arithmetic, so they can never disagree with the clamp they predict. One
31
- * trap in that estimator: it anchors on the last assistant message's
32
- * `usage` total when there is one (`withoutUsage`).
30
+ * arithmetic, so they can never disagree with the clamp they predict. They
31
+ * `normalizeContext` the request first, exactly as `completeSimple` does, so the
32
+ * prompt and tools are counted (pi 0.86.1 carries them in a leading system
33
+ * message, not separate `Context` fields). One trap in that estimator: it
34
+ * anchors on the last assistant message's `usage` total when there is one
35
+ * (`withoutUsage`).
33
36
  */
34
37
 
35
38
  import { type Api, type Context, contentText, type Message, type Model } from "@earendil-works/pi-ai";
36
39
  import { PREVIEW_BYTES } from "../lib/persisted-output.ts";
37
40
  // Vendored from pi-ai: importing its deep subpaths directly breaks under the
38
41
  // bundled app's library loader (distribution review 2026-09-09, H1).
39
- import { clampMaxTokensToContext, estimateMessageTokens } from "../lib/pi-ai-estimate.ts";
42
+ import { clampMaxTokensToContext, estimateMessageTokens, normalizeContext } from "../lib/pi-ai-estimate.ts";
40
43
 
41
44
  /**
42
45
  * The least output room a replay must leave for the summary. Below it the
@@ -60,7 +63,7 @@ export const CLEARED_RESULT_HEAD_CHARS = PREVIEW_BYTES;
60
63
  */
61
64
  export function replayFits(reason: "manual" | "threshold" | "overflow", model: Model<Api>, request: Context, maxTokens: number): boolean {
62
65
  if (reason === "overflow") return false;
63
- return clampMaxTokensToContext(model, request, maxTokens) >= MIN_REPLAY_SUMMARY_TOKENS;
66
+ return clampMaxTokensToContext(model, normalizeContext(request), maxTokens) >= MIN_REPLAY_SUMMARY_TOKENS;
64
67
  }
65
68
 
66
69
  /**
@@ -80,7 +83,7 @@ export function fitToBudget(model: Model<Api>, request: Context, maxTokens: numb
80
83
 
81
84
  let cleared = 0;
82
85
  for (const { index } of candidates) {
83
- if (clampMaxTokensToContext(model, fitted, maxTokens) >= STANDALONE_SUMMARY_TOKENS) break;
86
+ if (clampMaxTokensToContext(model, normalizeContext(fitted), maxTokens) >= STANDALONE_SUMMARY_TOKENS) break;
84
87
  messages[index] = clearToolResult(messages[index]);
85
88
  cleared++;
86
89
  }
@@ -17,7 +17,10 @@ import { readJsonFile } from "../lib/atomic-write.ts";
17
17
  import { oneCodeSettingsPath } from "../lib/one-code-settings.ts";
18
18
  import { webSearchSettingsFrom } from "../web/backends.ts";
19
19
  import { collectCompat, importedConfigSection, mcpSection } from "./compat.ts";
20
- import { checkDependencies, dependenciesSection } from "./dependencies.ts";
20
+ import { checkDependencies, dependenciesSection, type ShellsInput } from "./dependencies.ts";
21
+ import { readSettingsEnv } from "../lib/claude-settings.ts";
22
+ import { resolveBashSpawn, resolvePowerShellSpawn } from "../lib/shell-spawn.ts";
23
+ import { shellToolPolicy } from "../powershell/policy.ts";
21
24
  import { collectModelFacts, modelsSection } from "./models.ts";
22
25
  import { computePresets, presetsSection } from "./presets.ts";
23
26
  import {
@@ -181,7 +184,7 @@ export function buildDoctorReport({ env, registry, session }: BuildInput): Docto
181
184
  sections.push(mcpSection(compat, session, env.home));
182
185
 
183
186
  const webSearchSettings = webSearchSettingsFrom(readJsonFile<{ webSearch?: unknown }>(oneCodeSettingsPath(env.home, env.env))?.webSearch);
184
- const deps = checkDependencies({ cwd: env.cwd, env: env.env, platform: env.platform, mcpServers: compat.mcp.servers, sessionModel: session.model, webSearchSettings });
187
+ const deps = checkDependencies({ cwd: env.cwd, env: env.env, platform: env.platform, agentDir: env.agentDir, mcpServers: compat.mcp.servers, sessionModel: session.model, webSearchSettings, shells: resolveShells(env.env, env.home, env.platform) });
185
188
  findings.push(...deps.findings);
186
189
  sections.push(dependenciesSection(deps));
187
190
 
@@ -224,3 +227,20 @@ function summaryText(input: {
224
227
  export function oneCodeVersion(env: NodeJS.ProcessEnv = process.env): string {
225
228
  return env.CC_VERSION ?? extensionVersion();
226
229
  }
230
+
231
+ /** The shell tools exactly as a session resolves them (bash/index.ts, powershell/index.ts). */
232
+ export function resolveShells(procEnv: NodeJS.ProcessEnv, home: string, platform: string): ShellsInput {
233
+ const plat = platform as NodeJS.Platform;
234
+ const bash = resolveBashSpawn({ env: procEnv, settingsEnv: readSettingsEnv(home) });
235
+ const powershell = resolvePowerShellSpawn({ platform: plat, env: procEnv });
236
+ const policy = shellToolPolicy({ platform: plat, env: procEnv, bash: !!bash.spawn, powershell: !!powershell });
237
+ return {
238
+ bash: bash.spawn?.shell,
239
+ bashWarning: bash.warning,
240
+ powershell: powershell?.shell,
241
+ powershellWanted: policy.powershellWanted,
242
+ bashExpected: policy.bashExpected,
243
+ primary: policy.primary,
244
+ notices: policy.notices,
245
+ };
246
+ }
@@ -11,11 +11,12 @@
11
11
  * providers it depends on a third-party key.
12
12
  */
13
13
 
14
- import { accessSync, constants, existsSync } from "node:fs";
15
- import { delimiter, join } from "node:path";
14
+ import { existsSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { whichOnPath } from "../lib/which.ts";
16
17
  import type { Api, Model } from "@earendil-works/pi-ai";
17
18
  import { getProviderKind } from "pi-web-search/src/api.ts";
18
- import { INSTALL_HINTS } from "../lsp/install-hints.ts";
19
+ import { installHint, serverInstallHint } from "../lsp/install-hints.ts";
19
20
  import { SERVERS } from "../lsp/servers.ts";
20
21
  import { typescriptPreflight } from "../lsp/servers.ts";
21
22
  import type { McpServer } from "../mcp/config.ts";
@@ -43,30 +44,6 @@ export interface DependencyReport {
43
44
  findings: Finding[];
44
45
  }
45
46
 
46
- /** Minimal `which`: the first PATH entry holding an executable of that name. */
47
- export function whichOnPath(command: string, env: NodeJS.ProcessEnv, platform: string = process.platform): string | undefined {
48
- if (!command) return undefined;
49
- if (command.includes("/") || command.includes("\\")) return isExecutable(command) ? command : undefined;
50
- const extensions = platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
51
- for (const dir of (env.PATH ?? "").split(delimiter)) {
52
- if (!dir) continue;
53
- for (const ext of extensions) {
54
- const candidate = join(dir, command + ext);
55
- if (isExecutable(candidate)) return candidate;
56
- }
57
- }
58
- return undefined;
59
- }
60
-
61
- function isExecutable(path: string): boolean {
62
- try {
63
- accessSync(path, constants.X_OK);
64
- return true;
65
- } catch {
66
- return false;
67
- }
68
- }
69
-
70
47
  /** Languages the working directory plausibly contains, by the LSP table's own root markers. */
71
48
  export function projectLanguages(cwd: string): string[] {
72
49
  const seen = new Map<string, string>(); // command → languageId (one per server binary)
@@ -86,9 +63,45 @@ export interface DependencyInput {
86
63
  cwd: string;
87
64
  env: NodeJS.ProcessEnv;
88
65
  platform?: string;
66
+ /** pi's agent dir; its `bin/` holds the ripgrep pi downloads itself. */
67
+ agentDir?: string;
89
68
  mcpServers: McpServer[];
90
69
  sessionModel?: Model<Api>;
91
70
  webSearchSettings: WebSearchSettings;
71
+ /**
72
+ * The shell tools as the session resolves them (build.ts, from
73
+ * lib/shell-spawn.ts + powershell/policy.ts): which bash, which PowerShell,
74
+ * which one is primary, and the startup notices. On Windows this is the
75
+ * first thing a user asks the doctor — "which shells did you find?" — and
76
+ * the report had no line for it until 2026-09-19 (the Vultr VM session).
77
+ */
78
+ shells?: ShellsInput;
79
+ }
80
+
81
+ export interface ShellsInput {
82
+ /** The bash binary the bash tool runs, or undefined when none exists. */
83
+ bash?: string;
84
+ /** An ignored CLAUDE_CODE_GIT_BASH_PATH, in the resolver's words. */
85
+ bashWarning?: string;
86
+ /** The PowerShell executable the powershell tool would run, or undefined. */
87
+ powershell?: string;
88
+ /** From `shellToolPolicy`: the tool is meant to be on (env switch, else Windows). */
89
+ powershellWanted: boolean;
90
+ /** From `shellToolPolicy`: a bash is part of this platform's expected shape (not Windows). */
91
+ bashExpected: boolean;
92
+ primary: "powershell" | "bash" | "none";
93
+ notices: string[];
94
+ }
95
+
96
+ function bashReason(s: ShellsInput): string {
97
+ if (s.primary === "bash") return "the primary shell tool";
98
+ if (s.bash) return "the bash tool, alongside PowerShell";
99
+ return s.bashExpected ? "the bash tool" : "the bash tool (Git for Windows provides one)";
100
+ }
101
+
102
+ function powershellReason(s: ShellsInput): string {
103
+ if (!s.powershellWanted) return s.bashExpected ? "the powershell tool (off; CLAUDE_CODE_USE_POWERSHELL_TOOL=1 turns it on with a pwsh on PATH)" : "the powershell tool (off: CLAUDE_CODE_USE_POWERSHELL_TOOL=0)";
104
+ return s.primary === "powershell" ? "the primary shell tool" : "the powershell tool";
92
105
  }
93
106
 
94
107
  export function checkDependencies(input: DependencyInput): DependencyReport {
@@ -102,8 +115,27 @@ export function checkDependencies(input: DependencyInput): DependencyReport {
102
115
  checks.push({ name: "git", found: !!git, path: git, need: "required", reason: "repository detection, auto mode's recoverability check, worktree isolation", hint: "https://git-scm.com/downloads" });
103
116
  if (!git) findings.push({ level: "warn", text: "git is not on PATH: worktree isolation and auto mode's git-recoverability check are unavailable.", fix: "Install git and make sure it is on the PATH used to launch One Code." });
104
117
 
105
- const rg = which("rg");
106
- checks.push({ name: "rg", found: !!rg, path: rg, need: "optional", reason: "faster code search when the model shells out to ripgrep", hint: "brew install ripgrep" });
118
+ // pi downloads ripgrep into <agentDir>/bin on first run when PATH has none
119
+ // and puts that dir on the shell tools' PATH, so a copy there is "found".
120
+ const rgName = platform === "win32" ? "rg.exe" : "rg";
121
+ const bundledRg = input.agentDir ? join(input.agentDir, "bin", rgName) : undefined;
122
+ if (input.shells) {
123
+ const s = input.shells;
124
+ checks.push({ name: "bash", found: !!s.bash, path: s.bash, need: s.bashExpected ? "required" : "optional", reason: bashReason(s), hint: s.bashExpected ? undefined : "https://git-scm.com/download/win" });
125
+ checks.push({ name: "powershell", found: !!s.powershell, path: s.powershell, need: s.powershellWanted ? "required" : "unused", reason: powershellReason(s), hint: "https://aka.ms/powershell" });
126
+ if (s.bashWarning) findings.push({ level: "warn", text: s.bashWarning, fix: "Point CLAUDE_CODE_GIT_BASH_PATH at a bash.exe or sh.exe, or unset it to use Git for Windows' default location." });
127
+ for (const notice of s.notices) {
128
+ const switchSet = notice.includes("CLAUDE_CODE_USE_POWERSHELL_TOOL");
129
+ findings.push({
130
+ level: s.primary === "none" ? "error" : "warn",
131
+ text: notice,
132
+ fix: switchSet ? "Install PowerShell 7 (pwsh) or unset CLAUDE_CODE_USE_POWERSHELL_TOOL." : "Install Git for Windows or PowerShell 7.",
133
+ });
134
+ }
135
+ }
136
+
137
+ const rg = which("rg") ?? (bundledRg && existsSync(bundledRg) ? bundledRg : undefined);
138
+ checks.push({ name: "rg", found: !!rg, path: rg, need: "optional", reason: "faster code search when the model shells out to ripgrep", hint: installHint("ripgrep", platform) });
107
139
 
108
140
  const languages = projectLanguages(cwd);
109
141
  const seenCommands = new Set<string>();
@@ -119,13 +151,13 @@ export function checkDependencies(input: DependencyInput): DependencyReport {
119
151
  path: found,
120
152
  need: needed ? "project" : "unused",
121
153
  reason: `lsp_diagnostics for ${family.join("/")}${needed ? " (this project)" : ""}`,
122
- hint: INSTALL_HINTS[config.command],
154
+ hint: serverInstallHint(config.command, platform),
123
155
  });
124
156
  if (needed && !found) {
125
157
  findings.push({
126
158
  level: "warn",
127
159
  text: `${config.command} is not installed, so the model gets no language-server diagnostics after editing ${family[0]} files here.`,
128
- fix: INSTALL_HINTS[config.command] ? `Install it with: ${INSTALL_HINTS[config.command]}` : "Install it and make sure it is on your PATH.",
160
+ fix: serverInstallHint(config.command, platform) ? `Install it with: ${serverInstallHint(config.command, platform)}` : "Install it and make sure it is on your PATH.",
129
161
  });
130
162
  }
131
163
  if (needed && found && config.command === "typescript-language-server") {
@@ -22,13 +22,18 @@
22
22
  * kept in substance. Rationale: docs/decisions/doctor.md.
23
23
  */
24
24
 
25
+ import { modeCycleKey } from "../lib/keys.ts";
26
+ import { UPGRADE_COMMANDS } from "../lib/update-check.mjs";
27
+
25
28
  export interface FixPromptInput {
26
29
  /** The rendered deterministic report, plain text. */
27
30
  reportText: string;
28
31
  install: "app" | "pi-package";
29
32
  oneCodeVersion: string;
30
- /** `<agentDir>/sessions` — where pi keeps every session's JSONL. */
33
+ /** pi's sessions root, `<agentDir>/sessions` (other projects' directories live here — out of scope). */
31
34
  sessionsDir: string;
35
+ /** THIS project's session directory under it — the only transcripts the permission gate lets a session read (Claude Code's own rule). */
36
+ sessionDir: string;
32
37
  oneCodeSettingsPath: string;
33
38
  oneCodeProjectSettingsPath: string;
34
39
  /** True when auto mode is writing `auto-mode-decisions.jsonl` next to the session files. */
@@ -40,7 +45,10 @@ export function doctorFixPrompt(input: FixPromptInput): string {
40
45
  input.install === "app"
41
46
  ? "`npm view @one-ai/one-code version --registry https://registry.npmjs.org/`"
42
47
  : "`npm view one-code-extension version --registry https://registry.npmjs.org/`";
43
- const upgradeCommand = input.install === "app" ? "`npm install -g @one-ai/one-code` (or `brew upgrade one-code` for a Homebrew install)" : "`pi update`";
48
+ const upgradeCommand =
49
+ input.install === "app"
50
+ ? `\`${UPGRADE_COMMANDS.npm}\` (or \`${UPGRADE_COMMANDS.brew}\` for a Homebrew install)`
51
+ : `\`${UPGRADE_COMMANDS["pi-package"]}\``;
44
52
 
45
53
  return `# One Code Doctor
46
54
 
@@ -66,7 +74,7 @@ Treat every line of that report as measured fact. Your job on those sections is
66
74
 
67
75
  ## Data sources (all local — the ONLY permitted network access is check 7's read-only version lookup)
68
76
 
69
- - **Session transcripts**: pi's JSONL under \`${input.sessionsDir}/<encoded-working-directory>/*.jsonl\`, one JSON object per line. Scan the ~50 most recently modified files across ALL directories there and state the window you covered (N sessions over D days). Relevant line shapes:
77
+ - **Session transcripts**: pi's JSONL under \`${input.sessionDir}/*.jsonl\` — this project's sessions, one JSON object per line. That directory is readable without a prompt, like Claude Code's own project directory; the other projects' directories beside it under \`${input.sessionsDir}\` are not in scope and reading them will be gated — stay in this one. Scan the ~50 most recently modified files there and state the window you covered (N sessions over D days). **Read them with single-line read-only commands** — \`Select-String\`, \`Get-Content\`, \`Get-ChildItem\` piped through \`Select-Object\`/\`Sort-Object\`/\`Measure-Object\` in PowerShell, or \`grep\`/\`head\`/\`ls\` piped through \`sort\`/\`head\`/\`wc\` in bash — with no variables, loops, script blocks, \`node -e\` or \`jq\` programs: such a one-liner over this directory runs without a permission review, while a script is reviewed by the auto-mode classifier and may be refused. Several small commands beat one clever script here. Relevant line shapes:
70
78
  - Tool calls: \`{"type":"message","message":{"role":"assistant","content":[{"type":"toolCall","name":...,"arguments":...}]}}\`. MCP tools are named \`mcp__<server>__<tool>\` (the server segment is normalized — any character outside \`[a-zA-Z0-9_-]\` becomes \`_\`; plugin servers appear as \`mcp__plugin_<plugin>_<server>__\`). Model-invoked skills are calls to the \`skill\` tool with the skill name in \`arguments.skill\`. Subagent runs are \`Agent\` tool calls.
71
79
  - Denied tool calls: a \`toolResult\` whose text starts with "Permission for this action was denied by the auto-mode approval classifier" (auto mode) or "This tool call is denied by the permission rule" (a deny rule). Follow its \`toolCallId\` back to the assistant's \`toolCall\` for the tool name and arguments. ${input.decisionLogEnabled ? "Auto mode is also writing `auto-mode-decisions.jsonl` next to each session file — every gate decision with its verdict; prefer it for check 9." : "Auto mode is not logging its decisions (`autoMode.logDecisions` is off), so transcripts are the only denial record."}
72
80
  - There are NO usage counters in One Code: whether a skill, plugin or MCP server was used is answered from transcripts alone.
@@ -107,7 +115,7 @@ Installed: ${input.oneCodeVersion} (${input.install === "app" ? "the bundled one
107
115
 
108
116
  ## Check 8 — permission mode (report only)
109
117
 
110
- Auto mode is One Code's shipped default. If the report shows a different mode pinned by \`permissions.defaultMode\` in \`~/.claude/settings.json\` or a managed file, say so in one line and leave it — One Code does not edit those files, and the user can change the mode with ctrl+q or \`--permission-mode\`. If a repository file pins \`auto\` or \`bypassPermissions\`, the report already explains why it is ignored.
118
+ Auto mode is One Code's shipped default. If the report shows a different mode pinned by \`permissions.defaultMode\` in \`~/.claude/settings.json\` or a managed file, say so in one line and leave it — One Code does not edit those files, and the user can change the mode with ${modeCycleKey()} or \`--permission-mode\`. If a repository file pins \`auto\` or \`bypassPermissions\`, the report already explains why it is ignored.
111
119
 
112
120
  ## Check 9 — pre-approve frequently denied read-only commands
113
121
 
@@ -159,6 +159,7 @@ export default function doctorExtension(pi: ExtensionAPI) {
159
159
  install: install(),
160
160
  oneCodeVersion: oneCodeVersion(),
161
161
  sessionsDir: join(getAgentDir(), "sessions"),
162
+ sessionDir: ctx.sessionManager.getSessionDir(),
162
163
  oneCodeSettingsPath: oneCodeSettingsPath(home),
163
164
  oneCodeProjectSettingsPath: oneCodeProjectSettingsPath(ctx.cwd, home),
164
165
  decisionLogEnabled: loadAutoModeConfig(home).logDecisions,
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { Api, Model } from "@earendil-works/pi-ai";
13
+ import { modeCycleKey } from "../lib/keys.ts";
13
14
  import { loadAutoModeConfig } from "../auto-mode/config.ts";
14
15
  import { classifierCandidates, describeCandidate, type ClassifierNotice } from "../auto-mode/model-select.ts";
15
16
  import { ATTRIBUTION, capabilityFloor, configuredCapabilityKey, type FloorRole, type FloorVerdict, KEY_ADVICE, snapshotAgeMs } from "../lib/capability-index.ts";
@@ -210,7 +211,7 @@ export function modelsSection(facts: ModelFacts, session: SessionView, findings:
210
211
  if (session.permission) {
211
212
  const mode = session.permission.mode;
212
213
  const from = session.permission.source ? ` (${session.permission.source})` : "";
213
- lines.push({ text: `Permission mode: ${mode}${from} — ctrl+q cycles, /permissions lists the rules`, level: "dim" });
214
+ lines.push({ text: `Permission mode: ${mode}${from} — ${modeCycleKey()} cycles, /permissions lists the rules`, level: "dim" });
214
215
  }
215
216
 
216
217
  if (facts.sessionTier === "tiny") {
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { Api, Model } from "@earendil-works/pi-ai";
19
+ import { tildify } from "../lib/paths.ts";
19
20
  import type { McpStatusEvent } from "../lib/mcp-status.ts";
20
21
  import { wrapProse } from "../subagents/panel-render.ts";
21
22
  import { countNoun } from "../lib/tui-render.ts";
@@ -183,7 +184,5 @@ export function contextLabel(tokens: number | undefined): string {
183
184
 
184
185
  /** `~/…` for anything under home, so paths stay readable at any width. */
185
186
  export function shortenHome(path: string, home: string): string {
186
- if (path === home) return "~";
187
- const root = home.endsWith("/") ? home : `${home}/`;
188
- return path.startsWith(root) ? `~/${path.slice(root.length)}` : path;
187
+ return tildify(path, home);
189
188
  }
@@ -1,16 +1,19 @@
1
1
  /**
2
2
  * Newest-release lookup for the doctor's Installation section (pure given a
3
- * fetch). Mirrors the app's daily update check (app/update-check.mjs): the same
4
- * registry endpoints, a short timeout, silent on failure, and skipped under
5
- * pi's offline switch or ONECODE_NO_UPDATE_CHECK — a diagnostic must never hang
6
- * on the network or restore egress the user turned off.
3
+ * fetch). Shares the app's daily update notice's mechanism
4
+ * (lib/update-check.mjs): the same packument, the same "newest installable
5
+ * version" rule — a day's release age under Homebrew, `dist-tags.latest`
6
+ * otherwise — a short timeout, silent on failure, and skipped under pi's
7
+ * offline switch or ONECODE_NO_UPDATE_CHECK — a diagnostic must never hang on
8
+ * the network or restore egress the user turned off.
7
9
  */
8
10
 
9
11
  import { compareVersions } from "../lib/pi-version.ts";
12
+ import { APP_PACKAGE, EXTENSION_PACKAGE, minReleaseAgeFor, pickAvailableVersion, registryPackumentUrl } from "../lib/update-check.mjs";
10
13
  import type { DoctorEnvironment } from "./report.ts";
11
14
 
12
- export const APP_REGISTRY_URL = "https://registry.npmjs.org/@one-ai%2Fone-code/latest";
13
- export const PACKAGE_REGISTRY_URL = "https://registry.npmjs.org/one-code-extension/latest";
15
+ export const APP_REGISTRY_URL = registryPackumentUrl(APP_PACKAGE);
16
+ export const PACKAGE_REGISTRY_URL = registryPackumentUrl(EXTENSION_PACKAGE);
14
17
  export const LOOKUP_TIMEOUT_MS = 3000;
15
18
 
16
19
  export type LatestLookup = NonNullable<DoctorEnvironment["latest"]>;
@@ -34,11 +37,16 @@ export async function lookupLatestVersion(input: {
34
37
  try {
35
38
  const response = await fetchImpl(url, { signal: AbortSignal.timeout(input.timeoutMs ?? LOOKUP_TIMEOUT_MS) });
36
39
  if (!response.ok) return { status: "unknown", reason: `registry answered ${response.status}` };
37
- const body = (await response.json()) as { version?: unknown };
38
- if (typeof body.version !== "string") return { status: "unknown", reason: "unexpected registry payload" };
39
- const cmp = compareVersions(body.version, input.current);
40
- if (cmp === undefined) return { status: "unknown", version: body.version, reason: "unparseable version" };
41
- return { status: cmp > 0 ? "behind" : "current", version: body.version };
40
+ const minReleaseAgeMs = minReleaseAgeFor(input.env);
41
+ const body = (await response.json()) as { "dist-tags"?: { latest?: unknown }; time?: unknown };
42
+ const wellFormed = typeof body?.["dist-tags"]?.latest === "string" && (minReleaseAgeMs <= 0 || (typeof body.time === "object" && body.time !== null));
43
+ if (!wellFormed) return { status: "unknown", reason: "unexpected registry payload" };
44
+ const latest = pickAvailableVersion(body, { minReleaseAgeMs });
45
+ // A well-formed packument with nothing old enough is a Homebrew outcome, not a broken registry.
46
+ if (typeof latest !== "string") return { status: "unknown", reason: "no release is a day old yet (Homebrew installs wait that long)" };
47
+ const cmp = compareVersions(latest, input.current);
48
+ if (cmp === undefined) return { status: "unknown", version: latest, reason: "unparseable version" };
49
+ return { status: cmp > 0 ? "behind" : "current", version: latest };
42
50
  } catch (error) {
43
51
  const message = error instanceof Error ? (error.name === "TimeoutError" ? "timed out" : error.message) : String(error);
44
52
  return { status: "unknown", reason: message };
@@ -42,6 +42,7 @@ import {
42
42
  ULTRACODE_MODE_CHANNEL,
43
43
  ULTRACODE_STATUS_KEY,
44
44
  } from "./slider.ts";
45
+ import { registerLocalCommand } from "../lib/local-command.ts";
45
46
 
46
47
  /** The levels pi says the active model can reach; the full ladder when unknown. */
47
48
  function supportedLevels(ctx: ExtensionContext): ThinkingLevel[] {
@@ -193,7 +194,7 @@ export default function effortExtension(pi: ExtensionAPI) {
193
194
  if (chosen) applyChoice(chosen, ctx);
194
195
  };
195
196
 
196
- pi.registerCommand("effort", {
197
+ registerLocalCommand(pi, "effort", {
197
198
  description: `Set reasoning effort (shift+tab cycles the plain levels): /effort [${EFFORT_CHOICES.join("|")}] — ultracode is ${ULTRACODE_LEVEL} + workflows`,
198
199
  getArgumentCompletions: (prefix) => {
199
200
  const matches = acceptedEffortArgs().filter((value) => value.startsWith(prefix.trim().toLowerCase()));