@oh-my-pi/pi-coding-agent 3.31.0 → 3.32.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/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [3.32.0] - 2026-01-08
6
+ ### Added
7
+
8
+ - Added progress indicator when starting LSP servers at session startup
9
+ - Added bundled `/init` slash command available by default
10
+
11
+ ### Changed
12
+
13
+ - Changed LSP server warmup to use a 5-second timeout, falling back to lazy initialization for slow servers
14
+
15
+ ### Fixed
16
+
17
+ - Fixed Task tool subagent model selection to inherit explicit CLI `--model` overrides
18
+
5
19
  ## [3.31.0] - 2026-01-08
6
20
 
7
21
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "3.31.0",
3
+ "version": "3.32.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -40,9 +40,9 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@mariozechner/pi-ai": "^0.37.8",
43
- "@oh-my-pi/pi-agent-core": "3.31.0",
44
- "@oh-my-pi/pi-git-tool": "3.31.0",
45
- "@oh-my-pi/pi-tui": "3.31.0",
43
+ "@oh-my-pi/pi-agent-core": "3.32.0",
44
+ "@oh-my-pi/pi-git-tool": "3.32.0",
45
+ "@oh-my-pi/pi-tui": "3.32.0",
46
46
  "@openai/agents": "^0.3.7",
47
47
  "@sinclair/typebox": "^0.34.46",
48
48
  "ajv": "^8.17.1",
@@ -435,13 +435,11 @@ export function createReviewCommand(api: CustomCommandAPI): CustomCommand {
435
435
  if (hasDiff) {
436
436
  const stats = parseDiff(diffResult.stdout);
437
437
  // Even if all files filtered, include the custom instructions
438
- return (
439
- buildReviewPrompt(
440
- `Custom review: ${instructions.split("\n")[0].slice(0, 60)}...`,
441
- stats,
442
- diffResult.stdout,
443
- ) + `\n\n### Additional Instructions\n\n${instructions}`
444
- );
438
+ return `${buildReviewPrompt(
439
+ `Custom review: ${instructions.split("\n")[0].slice(0, 60)}...`,
440
+ stats,
441
+ diffResult.stdout,
442
+ )}\n\n### Additional Instructions\n\n${instructions}`;
445
443
  }
446
444
 
447
445
  // No diff available, just pass instructions
package/src/core/sdk.ts CHANGED
@@ -61,7 +61,7 @@ import { logger } from "./logger";
61
61
  import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp/index";
62
62
  import { convertToLlm } from "./messages";
63
63
  import { ModelRegistry } from "./model-registry";
64
- import { parseModelString } from "./model-resolver";
64
+ import { formatModelString, parseModelString } from "./model-resolver";
65
65
  import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./prompt-templates";
66
66
  import { SessionManager } from "./session-manager";
67
67
  import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager";
@@ -520,6 +520,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
520
520
  time("loadSession");
521
521
  const hasExistingSession = existingSession.messages.length > 0;
522
522
 
523
+ const hasExplicitModel = options.model !== undefined;
523
524
  let model = options.model;
524
525
  let modelFallbackMessage: string | undefined;
525
526
 
@@ -617,6 +618,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
617
618
  requireCompleteTool: options.requireCompleteTool,
618
619
  getSessionFile: () => sessionManager.getSessionFile() ?? null,
619
620
  getSessionSpawns: () => options.spawns ?? "*",
621
+ getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
620
622
  settings: settingsManager,
621
623
  };
622
624
 
@@ -931,7 +933,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
931
933
  let lspServers: CreateAgentSessionResult["lspServers"];
932
934
  if (settingsManager.getLspDiagnosticsOnWrite()) {
933
935
  try {
934
- const result = await warmupLspServers(cwd);
936
+ const result = await warmupLspServers(cwd, {
937
+ onConnecting: (serverNames) => {
938
+ if (options.hasUI && serverNames.length > 0) {
939
+ process.stderr.write(chalk.gray(`Starting LSP servers: ${serverNames.join(", ")}...\n`));
940
+ }
941
+ },
942
+ });
935
943
  lspServers = result.servers;
936
944
  time("warmupLspServers");
937
945
  } catch {
@@ -2,6 +2,7 @@ import { slashCommandCapability } from "../capability/slash-command";
2
2
  import type { SlashCommand } from "../discovery";
3
3
  import { loadSync } from "../discovery";
4
4
  import { parseFrontmatter } from "../discovery/helpers";
5
+ import { EMBEDDED_COMMAND_TEMPLATES } from "./tools/task/commands";
5
6
 
6
7
  /**
7
8
  * Represents a custom slash command loaded from a file
@@ -15,6 +16,25 @@ export interface FileSlashCommand {
15
16
  _source?: { providerName: string; level: "user" | "project" | "native" };
16
17
  }
17
18
 
19
+ const EMBEDDED_SLASH_COMMANDS = EMBEDDED_COMMAND_TEMPLATES;
20
+
21
+ function parseCommandTemplate(content: string): { description: string; body: string } {
22
+ const { frontmatter, body } = parseFrontmatter(content);
23
+ const frontmatterDesc = typeof frontmatter.description === "string" ? frontmatter.description.trim() : "";
24
+
25
+ // Get description from frontmatter or first non-empty line
26
+ let description = frontmatterDesc;
27
+ if (!description) {
28
+ const firstLine = body.split("\n").find((line) => line.trim());
29
+ if (firstLine) {
30
+ description = firstLine.slice(0, 60);
31
+ if (firstLine.length > 60) description += "...";
32
+ }
33
+ }
34
+
35
+ return { description, body };
36
+ }
37
+
18
38
  /**
19
39
  * Parse command arguments respecting quoted strings (bash-style)
20
40
  * Returns array of arguments
@@ -90,19 +110,8 @@ export interface LoadSlashCommandsOptions {
90
110
  export function loadSlashCommands(options: LoadSlashCommandsOptions = {}): FileSlashCommand[] {
91
111
  const result = loadSync<SlashCommand>(slashCommandCapability.id, { cwd: options.cwd });
92
112
 
93
- return result.items.map((cmd) => {
94
- const { frontmatter, body } = parseFrontmatter(cmd.content);
95
- const frontmatterDesc = typeof frontmatter.description === "string" ? frontmatter.description.trim() : "";
96
-
97
- // Get description from frontmatter or first non-empty line
98
- let description = frontmatterDesc;
99
- if (!description) {
100
- const firstLine = body.split("\n").find((line) => line.trim());
101
- if (firstLine) {
102
- description = firstLine.slice(0, 60);
103
- if (firstLine.length > 60) description += "...";
104
- }
105
- }
113
+ const fileCommands: FileSlashCommand[] = result.items.map((cmd) => {
114
+ const { description, body } = parseCommandTemplate(cmd.content);
106
115
 
107
116
  // Format source label: "via ProviderName Level"
108
117
  const capitalizedLevel = cmd.level.charAt(0).toUpperCase() + cmd.level.slice(1);
@@ -116,6 +125,23 @@ export function loadSlashCommands(options: LoadSlashCommandsOptions = {}): FileS
116
125
  _source: { providerName: cmd._source.providerName, level: cmd.level },
117
126
  };
118
127
  });
128
+
129
+ const seenNames = new Set(fileCommands.map((cmd) => cmd.name));
130
+ for (const cmd of EMBEDDED_SLASH_COMMANDS) {
131
+ const name = cmd.name.replace(/\.md$/, "");
132
+ if (seenNames.has(name)) continue;
133
+
134
+ const { description, body } = parseCommandTemplate(cmd.content);
135
+ fileCommands.push({
136
+ name,
137
+ description,
138
+ content: body,
139
+ source: "bundled",
140
+ });
141
+ seenNames.add(name);
142
+ }
143
+
144
+ return fileCommands;
119
145
  }
120
146
 
121
147
  /**
@@ -17,6 +17,7 @@ export {
17
17
  getLspStatus,
18
18
  type LspServerStatus,
19
19
  type LspToolDetails,
20
+ type LspWarmupOptions,
20
21
  type LspWarmupResult,
21
22
  warmupLspServers,
22
23
  } from "./lsp/index";
@@ -93,6 +94,8 @@ export interface ToolSession {
93
94
  getSessionFile: () => string | null;
94
95
  /** Get session spawns */
95
96
  getSessionSpawns: () => string | null;
97
+ /** Get resolved model string if explicitly set for this session */
98
+ getModelString?: () => string | undefined;
96
99
  /** Settings manager (optional) */
97
100
  settings?: {
98
101
  getImageAutoResize(): boolean;
@@ -148,7 +151,10 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
148
151
 
149
152
  const entries = requestedTools
150
153
  ? requestedTools.filter((name) => name in allTools).map((name) => [name, allTools[name]] as const)
151
- : [...Object.entries(BUILTIN_TOOLS), ...(includeComplete ? ([["complete", HIDDEN_TOOLS.complete]] as const) : [])];
154
+ : [
155
+ ...Object.entries(BUILTIN_TOOLS),
156
+ ...(includeComplete ? ([["complete", HIDDEN_TOOLS.complete]] as const) : []),
157
+ ];
152
158
  const results = await Promise.all(entries.map(([, factory]) => factory(session)));
153
159
  const tools = results.filter((t): t is Tool => t !== null);
154
160
 
@@ -373,10 +373,16 @@ async function sendResponse(
373
373
  // Client Management
374
374
  // =============================================================================
375
375
 
376
+ /** Timeout for warmup initialize requests (5 seconds) */
377
+ export const WARMUP_TIMEOUT_MS = 5000;
378
+
376
379
  /**
377
380
  * Get or create an LSP client for the given server configuration and working directory.
381
+ * @param config - Server configuration
382
+ * @param cwd - Working directory
383
+ * @param initTimeoutMs - Optional timeout for the initialize request (defaults to 30s)
378
384
  */
379
- export async function getOrCreateClient(config: ServerConfig, cwd: string): Promise<LspClient> {
385
+ export async function getOrCreateClient(config: ServerConfig, cwd: string, initTimeoutMs?: number): Promise<LspClient> {
380
386
  const key = `${config.command}:${cwd}`;
381
387
 
382
388
  // Check if client already exists
@@ -430,14 +436,20 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string): Prom
430
436
 
431
437
  try {
432
438
  // Send initialize request
433
- const initResult = (await sendRequest(client, "initialize", {
434
- processId: process.pid,
435
- rootUri: fileToUri(cwd),
436
- rootPath: cwd,
437
- capabilities: CLIENT_CAPABILITIES,
438
- initializationOptions: config.initOptions ?? {},
439
- workspaceFolders: [{ uri: fileToUri(cwd), name: cwd.split("/").pop() ?? "workspace" }],
440
- })) as { capabilities?: unknown };
439
+ const initResult = (await sendRequest(
440
+ client,
441
+ "initialize",
442
+ {
443
+ processId: process.pid,
444
+ rootUri: fileToUri(cwd),
445
+ rootPath: cwd,
446
+ capabilities: CLIENT_CAPABILITIES,
447
+ initializationOptions: config.initOptions ?? {},
448
+ workspaceFolders: [{ uri: fileToUri(cwd), name: cwd.split("/").pop() ?? "workspace" }],
449
+ },
450
+ undefined, // signal
451
+ initTimeoutMs,
452
+ )) as { capabilities?: unknown };
441
453
 
442
454
  if (!initResult) {
443
455
  throw new Error("Failed to initialize LSP: no response");
@@ -662,6 +674,9 @@ export function shutdownClient(key: string): void {
662
674
  // LSP Protocol Methods
663
675
  // =============================================================================
664
676
 
677
+ /** Default timeout for LSP requests (30 seconds) */
678
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
679
+
665
680
  /**
666
681
  * Send an LSP request and wait for response.
667
682
  */
@@ -670,6 +685,7 @@ export async function sendRequest(
670
685
  method: string,
671
686
  params: unknown,
672
687
  signal?: AbortSignal,
688
+ timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
673
689
  ): Promise<unknown> {
674
690
  // Atomically increment and capture request ID
675
691
  const id = ++client.requestId;
@@ -712,7 +728,7 @@ export async function sendRequest(
712
728
  cleanup();
713
729
  reject(err);
714
730
  }
715
- }, 30000);
731
+ }, timeoutMs);
716
732
  if (signal) {
717
733
  signal.addEventListener("abort", abortHandler, { once: true });
718
734
  if (signal.aborted) {
@@ -19,6 +19,7 @@ import {
19
19
  sendRequest,
20
20
  setIdleTimeout,
21
21
  syncContent,
22
+ WARMUP_TIMEOUT_MS,
22
23
  } from "./client";
23
24
  import { getLinterClient } from "./clients";
24
25
  import { getServersForFile, hasCapability, type LspConfig, loadConfig } from "./config";
@@ -72,23 +73,36 @@ export interface LspWarmupResult {
72
73
  }>;
73
74
  }
74
75
 
76
+ /** Options for warming up LSP servers */
77
+ export interface LspWarmupOptions {
78
+ /** Called when starting to connect to servers */
79
+ onConnecting?: (serverNames: string[]) => void;
80
+ }
81
+
75
82
  /**
76
83
  * Warm up LSP servers for a directory by connecting to all detected servers.
77
84
  * This should be called at startup to avoid cold-start delays.
78
85
  *
79
86
  * @param cwd - Working directory to detect and start servers for
87
+ * @param options - Optional callbacks for progress reporting
80
88
  * @returns Status of each server that was started
81
89
  */
82
- export async function warmupLspServers(cwd: string): Promise<LspWarmupResult> {
90
+ export async function warmupLspServers(cwd: string, options?: LspWarmupOptions): Promise<LspWarmupResult> {
83
91
  const config = await loadConfig(cwd);
84
92
  setIdleTimeout(config.idleTimeoutMs);
85
93
  const servers: LspWarmupResult["servers"] = [];
86
94
  const lspServers = getLspServers(config);
87
95
 
88
- // Start all detected servers in parallel
96
+ // Notify caller which servers we're connecting to
97
+ if (lspServers.length > 0 && options?.onConnecting) {
98
+ options.onConnecting(lspServers.map(([name]) => name));
99
+ }
100
+
101
+ // Start all detected servers in parallel with a short timeout
102
+ // Servers that don't respond quickly will be initialized lazily on first use
89
103
  const results = await Promise.allSettled(
90
104
  lspServers.map(async ([name, serverConfig]) => {
91
- const client = await getOrCreateClient(serverConfig, cwd);
105
+ const client = await getOrCreateClient(serverConfig, cwd, WARMUP_TIMEOUT_MS);
92
106
  return { name, client, fileTypes: serverConfig.fileTypes };
93
107
  }),
94
108
  );
@@ -12,13 +12,17 @@ import { loadSync } from "../../../discovery";
12
12
  import architectPlanMd from "../../../prompts/architect-plan.md" with { type: "text" };
13
13
  import implementMd from "../../../prompts/implement.md" with { type: "text" };
14
14
  import implementWithCriticMd from "../../../prompts/implement-with-critic.md" with { type: "text" };
15
+ import initMd from "../../../prompts/init.md" with { type: "text" };
15
16
 
16
17
  const EMBEDDED_COMMANDS: { name: string; content: string }[] = [
17
18
  { name: "architect-plan.md", content: architectPlanMd },
18
19
  { name: "implement-with-critic.md", content: implementWithCriticMd },
19
20
  { name: "implement.md", content: implementMd },
21
+ { name: "init.md", content: initMd },
20
22
  ];
21
23
 
24
+ export const EMBEDDED_COMMAND_TEMPLATES: ReadonlyArray<{ name: string; content: string }> = EMBEDDED_COMMANDS;
25
+
22
26
  /** Workflow command definition */
23
27
  export interface WorkflowCommand {
24
28
  name: string;
@@ -135,6 +135,7 @@ export async function createTaskTool(
135
135
  const startTime = Date.now();
136
136
  const { agents, projectAgentsDir } = await discoverAgents(session.cwd);
137
137
  const { agent: agentName, context, model, output: outputSchema } = params;
138
+ const modelOverride = model ?? session.getModelString?.();
138
139
 
139
140
  // Validate agent exists
140
141
  const agent = getAgent(agents, agentName);
@@ -323,7 +324,7 @@ export async function createTaskTool(
323
324
  toolCount: 0,
324
325
  tokens: 0,
325
326
  durationMs: 0,
326
- modelOverride: model,
327
+ modelOverride,
327
328
  description: t.description,
328
329
  });
329
330
  }
@@ -342,7 +343,7 @@ export async function createTaskTool(
342
343
  index,
343
344
  taskId: task.taskId,
344
345
  context: undefined, // Already prepended above
345
- modelOverride: model,
346
+ modelOverride,
346
347
  outputSchema,
347
348
  sessionFile,
348
349
  persistArtifacts: !!artifactsDir,
package/src/main.ts CHANGED
@@ -5,10 +5,10 @@
5
5
  * createAgentSession() options. The SDK does the heavy lifting.
6
6
  */
7
7
 
8
- import { type ImageContent, supportsXhigh } from "@mariozechner/pi-ai";
9
- import chalk from "chalk";
10
8
  import { homedir, tmpdir } from "node:os";
11
9
  import { join, resolve } from "node:path";
10
+ import { type ImageContent, supportsXhigh } from "@mariozechner/pi-ai";
11
+ import chalk from "chalk";
12
12
  import { type Args, parseArgs, printHelp } from "./cli/args";
13
13
  import { processFileArguments } from "./cli/file-processor";
14
14
  import { listModels } from "./cli/list-models";
@@ -1906,31 +1906,6 @@ export class InteractiveMode {
1906
1906
  this.voiceSupervisor.notifyProgress(text);
1907
1907
  }
1908
1908
 
1909
- private async toggleVoiceListening(): Promise<void> {
1910
- if (!this.settingsManager.getVoiceEnabled()) {
1911
- this.settingsManager.setVoiceEnabled(true);
1912
- this.showStatus("Voice mode enabled.");
1913
- }
1914
-
1915
- if (this.voiceAutoModeEnabled) {
1916
- this.voiceAutoModeEnabled = false;
1917
- this.stopVoiceProgressTimer();
1918
- await this.voiceSupervisor.stop();
1919
- this.setVoiceStatus(undefined);
1920
- this.showStatus("Voice mode disabled.");
1921
- return;
1922
- }
1923
-
1924
- this.voiceAutoModeEnabled = true;
1925
- try {
1926
- await this.voiceSupervisor.start();
1927
- } catch (error) {
1928
- this.voiceAutoModeEnabled = false;
1929
- this.setVoiceStatus(undefined);
1930
- this.showError(error instanceof Error ? error.message : String(error));
1931
- }
1932
- }
1933
-
1934
1909
  private async submitVoiceText(text: string): Promise<void> {
1935
1910
  const cleaned = text.trim();
1936
1911
  if (!cleaned) {