@oh-my-pi/pi-coding-agent 3.15.1 → 3.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +51 -1
  2. package/docs/extensions.md +1055 -0
  3. package/docs/rpc.md +69 -13
  4. package/docs/session-tree-plan.md +1 -1
  5. package/examples/extensions/README.md +141 -0
  6. package/examples/extensions/api-demo.ts +87 -0
  7. package/examples/extensions/chalk-logger.ts +26 -0
  8. package/examples/extensions/hello.ts +33 -0
  9. package/examples/extensions/pirate.ts +44 -0
  10. package/examples/extensions/plan-mode.ts +551 -0
  11. package/examples/extensions/subagent/agents/reviewer.md +35 -0
  12. package/examples/extensions/todo.ts +299 -0
  13. package/examples/extensions/tools.ts +145 -0
  14. package/examples/extensions/with-deps/index.ts +36 -0
  15. package/examples/extensions/with-deps/package-lock.json +31 -0
  16. package/examples/extensions/with-deps/package.json +16 -0
  17. package/examples/sdk/02-custom-model.ts +3 -3
  18. package/examples/sdk/05-tools.ts +7 -3
  19. package/examples/sdk/06-extensions.ts +81 -0
  20. package/examples/sdk/06-hooks.ts +14 -13
  21. package/examples/sdk/08-prompt-templates.ts +42 -0
  22. package/examples/sdk/08-slash-commands.ts +17 -12
  23. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  24. package/examples/sdk/12-full-control.ts +6 -6
  25. package/package.json +11 -7
  26. package/src/capability/extension-module.ts +34 -0
  27. package/src/cli/args.ts +22 -7
  28. package/src/cli/file-processor.ts +38 -67
  29. package/src/cli/list-models.ts +1 -1
  30. package/src/config.ts +25 -14
  31. package/src/core/agent-session.ts +505 -242
  32. package/src/core/auth-storage.ts +33 -21
  33. package/src/core/compaction/branch-summarization.ts +4 -4
  34. package/src/core/compaction/compaction.ts +3 -3
  35. package/src/core/custom-commands/bundled/wt/index.ts +430 -0
  36. package/src/core/custom-commands/loader.ts +9 -0
  37. package/src/core/custom-tools/wrapper.ts +5 -0
  38. package/src/core/event-bus.ts +59 -0
  39. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  40. package/src/core/export-html/vendor/marked.min.js +6 -0
  41. package/src/core/extensions/index.ts +100 -0
  42. package/src/core/extensions/loader.ts +501 -0
  43. package/src/core/extensions/runner.ts +477 -0
  44. package/src/core/extensions/types.ts +712 -0
  45. package/src/core/extensions/wrapper.ts +147 -0
  46. package/src/core/hooks/types.ts +2 -2
  47. package/src/core/index.ts +10 -21
  48. package/src/core/keybindings.ts +199 -0
  49. package/src/core/messages.ts +26 -7
  50. package/src/core/model-registry.ts +123 -46
  51. package/src/core/model-resolver.ts +7 -5
  52. package/src/core/prompt-templates.ts +242 -0
  53. package/src/core/sdk.ts +378 -295
  54. package/src/core/session-manager.ts +72 -58
  55. package/src/core/settings-manager.ts +118 -22
  56. package/src/core/system-prompt.ts +24 -1
  57. package/src/core/terminal-notify.ts +37 -0
  58. package/src/core/tools/context.ts +4 -4
  59. package/src/core/tools/exa/mcp-client.ts +5 -4
  60. package/src/core/tools/exa/render.ts +176 -131
  61. package/src/core/tools/gemini-image.ts +361 -0
  62. package/src/core/tools/git.ts +216 -0
  63. package/src/core/tools/index.ts +28 -15
  64. package/src/core/tools/lsp/config.ts +5 -4
  65. package/src/core/tools/lsp/index.ts +17 -12
  66. package/src/core/tools/lsp/render.ts +39 -47
  67. package/src/core/tools/read.ts +66 -29
  68. package/src/core/tools/render-utils.ts +268 -0
  69. package/src/core/tools/renderers.ts +243 -225
  70. package/src/core/tools/task/discovery.ts +2 -2
  71. package/src/core/tools/task/executor.ts +66 -58
  72. package/src/core/tools/task/index.ts +29 -10
  73. package/src/core/tools/task/model-resolver.ts +8 -13
  74. package/src/core/tools/task/omp-command.ts +24 -0
  75. package/src/core/tools/task/render.ts +35 -60
  76. package/src/core/tools/task/types.ts +3 -0
  77. package/src/core/tools/web-fetch.ts +29 -28
  78. package/src/core/tools/web-search/index.ts +6 -5
  79. package/src/core/tools/web-search/providers/exa.ts +6 -5
  80. package/src/core/tools/web-search/render.ts +66 -111
  81. package/src/core/voice-controller.ts +135 -0
  82. package/src/core/voice-supervisor.ts +1003 -0
  83. package/src/core/voice.ts +308 -0
  84. package/src/discovery/builtin.ts +75 -1
  85. package/src/discovery/claude.ts +47 -1
  86. package/src/discovery/codex.ts +54 -2
  87. package/src/discovery/gemini.ts +55 -2
  88. package/src/discovery/helpers.ts +100 -1
  89. package/src/discovery/index.ts +2 -0
  90. package/src/index.ts +14 -9
  91. package/src/lib/worktree/collapse.ts +179 -0
  92. package/src/lib/worktree/constants.ts +14 -0
  93. package/src/lib/worktree/errors.ts +23 -0
  94. package/src/lib/worktree/git.ts +110 -0
  95. package/src/lib/worktree/index.ts +23 -0
  96. package/src/lib/worktree/operations.ts +216 -0
  97. package/src/lib/worktree/session.ts +114 -0
  98. package/src/lib/worktree/stats.ts +67 -0
  99. package/src/main.ts +61 -37
  100. package/src/migrations.ts +37 -7
  101. package/src/modes/interactive/components/bash-execution.ts +6 -4
  102. package/src/modes/interactive/components/custom-editor.ts +55 -0
  103. package/src/modes/interactive/components/custom-message.ts +95 -0
  104. package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
  105. package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
  106. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  107. package/src/modes/interactive/components/extensions/types.ts +1 -0
  108. package/src/modes/interactive/components/footer.ts +324 -0
  109. package/src/modes/interactive/components/hook-selector.ts +3 -3
  110. package/src/modes/interactive/components/model-selector.ts +7 -6
  111. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  112. package/src/modes/interactive/components/settings-defs.ts +55 -6
  113. package/src/modes/interactive/components/status-line.ts +45 -37
  114. package/src/modes/interactive/components/tool-execution.ts +95 -23
  115. package/src/modes/interactive/interactive-mode.ts +643 -113
  116. package/src/modes/interactive/theme/defaults/index.ts +16 -16
  117. package/src/modes/print-mode.ts +14 -72
  118. package/src/modes/rpc/rpc-client.ts +23 -9
  119. package/src/modes/rpc/rpc-mode.ts +137 -125
  120. package/src/modes/rpc/rpc-types.ts +46 -24
  121. package/src/prompts/task.md +1 -0
  122. package/src/prompts/tools/gemini-image.md +4 -0
  123. package/src/prompts/tools/git.md +9 -0
  124. package/src/prompts/voice-summary.md +12 -0
  125. package/src/utils/image-convert.ts +26 -0
  126. package/src/utils/image-resize.ts +215 -0
  127. package/src/utils/shell-snapshot.ts +22 -20
@@ -154,7 +154,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentDefinition[]
154
154
  *
155
155
  * @param cwd - Current working directory for project agent discovery
156
156
  */
157
- export function discoverAgents(cwd: string): DiscoveryResult {
157
+ export async function discoverAgents(cwd: string): Promise<DiscoveryResult> {
158
158
  const resolvedCwd = path.resolve(cwd);
159
159
  const agentSources = Array.from(new Set(getConfigDirs("", { project: false }).map((entry) => entry.source)));
160
160
 
@@ -167,7 +167,7 @@ export function discoverAgents(cwd: string): DiscoveryResult {
167
167
  }));
168
168
 
169
169
  // Get project directories by walking up from cwd (priority order)
170
- const projectDirs = findAllNearestProjectConfigDirs("agents", resolvedCwd)
170
+ const projectDirs = (await findAllNearestProjectConfigDirs("agents", resolvedCwd))
171
171
  .filter((entry) => agentSources.includes(entry.source))
172
172
  .map((entry) => ({
173
173
  ...entry,
@@ -5,13 +5,12 @@
5
5
  * Parses JSON events for progress tracking.
6
6
  */
7
7
 
8
- import { spawn } from "node:child_process";
9
- import * as fs from "node:fs";
10
- import * as os from "node:os";
8
+ import { existsSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
11
10
  import * as path from "node:path";
12
- import * as readline from "node:readline";
13
11
  import { ensureArtifactsDir, getArtifactPaths } from "./artifacts";
14
12
  import { resolveModelPattern } from "./model-resolver";
13
+ import { resolveOmpCommand } from "./omp-command";
15
14
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
16
15
  import {
17
16
  type AgentDefinition,
@@ -23,17 +22,12 @@ import {
23
22
  type SingleResult,
24
23
  } from "./types";
25
24
 
26
- /** omp command: 'omp.cmd' on Windows, 'omp' elsewhere */
27
- const OMP_CMD = process.platform === "win32" ? "omp.cmd" : "omp";
28
-
29
- /** Windows shell option for spawn */
30
- const OMP_SHELL_OPT = process.platform === "win32";
31
-
32
25
  /** Options for subprocess execution */
33
26
  export interface ExecutorOptions {
34
27
  cwd: string;
35
28
  agent: AgentDefinition;
36
29
  task: string;
30
+ description?: string;
37
31
  index: number;
38
32
  context?: string;
39
33
  modelOverride?: string;
@@ -146,6 +140,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
146
140
  agentSource: agent.source,
147
141
  status: "running",
148
142
  task,
143
+ description: options.description,
149
144
  recentTools: [],
150
145
  recentOutput: [],
151
146
  toolCount: 0,
@@ -161,6 +156,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
161
156
  agent: agent.name,
162
157
  agentSource: agent.source,
163
158
  task,
159
+ description: options.description,
164
160
  exitCode: 1,
165
161
  output: "",
166
162
  stderr: "Aborted before start",
@@ -173,20 +169,21 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
173
169
  }
174
170
 
175
171
  // Write system prompt to temp file
176
- const tempDir = os.tmpdir();
172
+ const tempDir = tmpdir();
177
173
  const promptFile = path.join(
178
174
  tempDir,
179
175
  `omp-agent-${agent.name}-${Date.now()}-${Math.random().toString(36).slice(2)}.md`,
180
176
  );
181
177
 
182
178
  try {
183
- fs.writeFileSync(promptFile, agent.systemPrompt, "utf-8");
179
+ writeFileSync(promptFile, agent.systemPrompt, "utf-8");
184
180
  } catch (err) {
185
181
  return {
186
182
  index,
187
183
  agent: agent.name,
188
184
  agentSource: agent.source,
189
185
  task,
186
+ description: options.description,
190
187
  exitCode: 1,
191
188
  output: "",
192
189
  stderr: `Failed to write prompt file: ${err}`,
@@ -212,7 +209,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
212
209
 
213
210
  // Write input file immediately (real-time visibility)
214
211
  try {
215
- fs.writeFileSync(artifactPaths.inputPath, fullTask, "utf-8");
212
+ writeFileSync(artifactPaths.inputPath, fullTask, "utf-8");
216
213
  } catch {
217
214
  // Non-fatal, continue without input artifact
218
215
  }
@@ -268,10 +265,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
268
265
  }
269
266
 
270
267
  // Spawn subprocess
271
- const proc = spawn(OMP_CMD, args, {
268
+ const ompCommand = resolveOmpCommand();
269
+ const proc = Bun.spawn([ompCommand.cmd, ...ompCommand.args, ...args], {
272
270
  cwd,
273
- stdio: ["ignore", "pipe", "pipe"],
274
- shell: OMP_SHELL_OPT,
271
+ stdin: "ignore",
272
+ stdout: "pipe",
273
+ stderr: "pipe",
275
274
  env,
276
275
  });
277
276
 
@@ -285,7 +284,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
285
284
  // Handle abort signal
286
285
  const onAbort = () => {
287
286
  if (!resolved) {
288
- proc.kill("SIGTERM");
287
+ proc.kill(15); // SIGTERM
289
288
  }
290
289
  };
291
290
  if (signal) {
@@ -293,9 +292,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
293
292
  }
294
293
 
295
294
  // Parse JSON events from stdout
296
- const rl = readline.createInterface({ input: proc.stdout! });
295
+ const reader = proc.stdout.getReader();
296
+ const decoder = new TextDecoder();
297
+ let buffer = "";
297
298
 
298
- rl.on("line", (line) => {
299
+ const processLine = (line: string) => {
299
300
  if (resolved) return;
300
301
 
301
302
  try {
@@ -362,7 +363,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
362
363
  setTimeout(() => {
363
364
  if (!resolved) {
364
365
  resolved = true;
365
- proc.kill("SIGTERM");
366
+ proc.kill(15); // SIGTERM
366
367
  }
367
368
  }, 2000);
368
369
  }
@@ -406,7 +407,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
406
407
  // If pending termination, now we have tokens - terminate
407
408
  if (pendingTermination && !resolved) {
408
409
  resolved = true;
409
- proc.kill("SIGTERM");
410
+ proc.kill(15); // SIGTERM
410
411
  }
411
412
  break;
412
413
  }
@@ -433,45 +434,49 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
433
434
  } catch {
434
435
  // Ignore non-JSON lines
435
436
  }
436
- });
437
-
438
- // Capture stderr
439
- const stderrDecoder = new TextDecoder();
440
- proc.stderr?.on("data", (chunk: Buffer) => {
441
- stderr += stderrDecoder.decode(chunk, { stream: true });
442
- });
437
+ };
443
438
 
444
- // Wait for readline to finish BEFORE resolving
445
- const exitCode = await new Promise<number>((resolve) => {
446
- let code: number | null = null;
447
- let rlClosed = false;
448
- let procClosed = false;
439
+ // Read stdout asynchronously
440
+ const stdoutDone = (async () => {
441
+ try {
442
+ while (true) {
443
+ const { done, value } = await reader.read();
444
+ if (done) break;
445
+ buffer += decoder.decode(value, { stream: true });
446
+ const lines = buffer.split("\n");
447
+ buffer = lines.pop() || "";
448
+ for (const line of lines) {
449
+ processLine(line);
450
+ }
451
+ }
452
+ // Process remaining buffer
453
+ if (buffer.trim()) {
454
+ processLine(buffer);
455
+ }
456
+ } catch {
457
+ // Ignore read errors
458
+ }
459
+ })();
449
460
 
450
- const maybeResolve = () => {
451
- if (rlClosed && procClosed) {
452
- resolved = true;
453
- resolve(code ?? 1);
461
+ // Capture stderr - Bun.spawn returns ReadableStream, convert to text
462
+ const stderrDone = (async () => {
463
+ try {
464
+ const stderrReader = proc.stderr.getReader();
465
+ const stderrDecoder = new TextDecoder();
466
+ while (true) {
467
+ const { done, value } = await stderrReader.read();
468
+ if (done) break;
469
+ stderr += stderrDecoder.decode(value, { stream: true });
454
470
  }
455
- };
471
+ } catch {
472
+ // Ignore stderr read errors
473
+ }
474
+ })();
456
475
 
457
- rl.on("close", () => {
458
- rlClosed = true;
459
- maybeResolve();
460
- });
461
-
462
- proc.on("close", (c) => {
463
- code = c;
464
- procClosed = true;
465
- maybeResolve();
466
- });
467
-
468
- proc.on("error", (err) => {
469
- stderr += `\nProcess error: ${err.message}`;
470
- code = 1;
471
- procClosed = true;
472
- maybeResolve();
473
- });
474
- });
476
+ // Wait for process and stream readers to finish
477
+ const exitCode = await proc.exited;
478
+ await Promise.all([stdoutDone, stderrDone]);
479
+ resolved = true;
475
480
 
476
481
  // Cleanup
477
482
  if (signal) {
@@ -479,7 +484,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
479
484
  }
480
485
 
481
486
  try {
482
- fs.unlinkSync(promptFile);
487
+ if (existsSync(promptFile)) {
488
+ unlinkSync(promptFile);
489
+ }
483
490
  } catch {
484
491
  // Ignore cleanup errors
485
492
  }
@@ -493,7 +500,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
493
500
  let outputMeta: { lineCount: number; charCount: number } | undefined;
494
501
  if (artifactPaths) {
495
502
  try {
496
- fs.writeFileSync(artifactPaths.outputPath, rawOutput, "utf-8");
503
+ writeFileSync(artifactPaths.outputPath, rawOutput, "utf-8");
497
504
  outputMeta = {
498
505
  lineCount: rawOutput.split("\n").length,
499
506
  charCount: rawOutput.length,
@@ -514,6 +521,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
514
521
  agent: agent.name,
515
522
  agentSource: agent.source,
516
523
  task,
524
+ description: options.description,
517
525
  exitCode,
518
526
  output: truncatedOutput,
519
527
  stderr,
@@ -16,11 +16,12 @@
16
16
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
17
17
  import type { Usage } from "@oh-my-pi/pi-ai";
18
18
  import type { Theme } from "../../../modes/interactive/theme/theme";
19
+ import { formatDuration } from "../render-utils";
19
20
  import { cleanupTempDir, createTempArtifactsDir, getArtifactsDir } from "./artifacts";
20
21
  import { discoverAgents, getAgent } from "./discovery";
21
22
  import { runSubprocess } from "./executor";
22
23
  import { mapWithConcurrencyLimit } from "./parallel";
23
- import { formatDuration, renderCall, renderResult } from "./render";
24
+ import { renderCall, renderResult } from "./render";
24
25
  import {
25
26
  type AgentProgress,
26
27
  MAX_AGENTS_IN_DESCRIPTION,
@@ -137,8 +138,8 @@ export { taskSchema } from "./types";
137
138
  /**
138
139
  * Build dynamic tool description listing available agents.
139
140
  */
140
- function buildDescription(cwd: string): string {
141
- const { agents } = discoverAgents(cwd);
141
+ async function buildDescription(cwd: string): Promise<string> {
142
+ const { agents } = await discoverAgents(cwd);
142
143
 
143
144
  const lines: string[] = [];
144
145
 
@@ -194,7 +195,7 @@ function buildDescription(cwd: string): string {
194
195
  lines.push("");
195
196
  lines.push("Parameters:");
196
197
  lines.push(
197
- `- tasks: Array of {agent, task, model?} - tasks to run in parallel (max ${MAX_PARALLEL_TASKS}, ${MAX_CONCURRENCY} concurrent)`,
198
+ `- tasks: Array of {agent, task, description?, model?} - tasks to run in parallel (max ${MAX_PARALLEL_TASKS}, ${MAX_CONCURRENCY} concurrent)`,
198
199
  );
199
200
  lines.push(
200
201
  ' - model: (optional) Override the agent\'s default model with fuzzy matching (e.g., "sonnet", "codex", "5.2"). Supports comma-separated fallbacks: "gpt, opus" tries gpt first, then opus. Use "default" for omp\'s default model',
@@ -257,11 +258,11 @@ function buildDescription(cwd: string): string {
257
258
  /**
258
259
  * Create the task tool configured for a specific working directory.
259
260
  */
260
- export function createTaskTool(
261
+ export async function createTaskTool(
261
262
  cwd: string,
262
263
  sessionContext?: SessionContext,
263
264
  options?: TaskToolOptions,
264
- ): AgentTool<typeof taskSchema, TaskToolDetails, Theme> {
265
+ ): Promise<AgentTool<typeof taskSchema, TaskToolDetails, Theme>> {
265
266
  const hasOutputTool = options?.availableTools?.has("output") ?? false;
266
267
  // Check if subagents are completely inhibited (legacy recursion prevention)
267
268
  if (process.env[OMP_NO_SUBAGENTS_ENV]) {
@@ -287,13 +288,13 @@ export function createTaskTool(
287
288
  return {
288
289
  name: "task",
289
290
  label: "Task",
290
- description: buildDescription(cwd),
291
+ description: await buildDescription(cwd),
291
292
  parameters: taskSchema,
292
293
  renderCall,
293
294
  renderResult,
294
295
  execute: async (_toolCallId, params, signal, onUpdate) => {
295
296
  const startTime = Date.now();
296
- const { agents, projectAgentsDir } = discoverAgents(cwd);
297
+ const { agents, projectAgentsDir } = await discoverAgents(cwd);
297
298
  const context = params.context;
298
299
 
299
300
  // Handle empty or missing tasks
@@ -435,6 +436,7 @@ export function createTaskTool(
435
436
  tokens: 0,
436
437
  durationMs: 0,
437
438
  modelOverride: tasks[i].model,
439
+ description: tasks[i].description,
438
440
  });
439
441
  }
440
442
  emitProgress();
@@ -444,6 +446,7 @@ export function createTaskTool(
444
446
  agent: t.agent,
445
447
  task: context ? `${context}\n\n${t.task}` : t.task,
446
448
  model: t.model,
449
+ description: t.description,
447
450
  }));
448
451
 
449
452
  // Execute in parallel with concurrency limit
@@ -453,6 +456,7 @@ export function createTaskTool(
453
456
  cwd,
454
457
  agent,
455
458
  task: task.task,
459
+ description: task.description,
456
460
  index,
457
461
  context: undefined, // Already prepended above
458
462
  modelOverride: task.model,
@@ -544,5 +548,20 @@ export function createTaskTool(
544
548
  };
545
549
  }
546
550
 
547
- // Default task tool using process.cwd()
548
- export const taskTool = createTaskTool(process.cwd());
551
+ // Default task tool using process.cwd() - returns a placeholder sync tool
552
+ // Real implementations should use createTaskTool() which properly initializes the tool
553
+ export const taskTool: AgentTool<typeof taskSchema, TaskToolDetails, Theme> = {
554
+ name: "task",
555
+ label: "Task",
556
+ description:
557
+ "Launch a new agent to handle complex, multi-step tasks autonomously. (Agent discovery pending - use createTaskTool for full functionality)",
558
+ parameters: taskSchema,
559
+ execute: async () => ({
560
+ content: [{ type: "text", text: "Task tool not properly initialized. Use createTaskTool(cwd) instead." }],
561
+ details: {
562
+ projectAgentsDir: null,
563
+ results: [],
564
+ totalDurationMs: 0,
565
+ },
566
+ }),
567
+ };
@@ -11,15 +11,9 @@
11
11
  * - "omp/slow" → configured slow model from settings
12
12
  */
13
13
 
14
- import { spawnSync } from "node:child_process";
15
14
  import { type Settings, settingsCapability } from "../../../capability/settings";
16
15
  import { loadSync } from "../../../discovery";
17
-
18
- /** omp command: 'omp.cmd' on Windows, 'omp' elsewhere */
19
- const OMP_CMD = process.platform === "win32" ? "omp.cmd" : "omp";
20
-
21
- /** Windows shell option for spawn/spawnSync */
22
- const OMP_SHELL_OPT = process.platform === "win32";
16
+ import { resolveOmpCommand } from "./omp-command";
23
17
 
24
18
  /** Cache for available models (provider/modelId format) */
25
19
  let cachedModels: string[] | null = null;
@@ -41,20 +35,21 @@ export function getAvailableModels(): string[] {
41
35
  }
42
36
 
43
37
  try {
44
- const result = spawnSync(OMP_CMD, ["--list-models"], {
45
- encoding: "utf-8",
46
- timeout: 5000,
47
- shell: OMP_SHELL_OPT,
38
+ const ompCommand = resolveOmpCommand();
39
+ const result = Bun.spawnSync([ompCommand.cmd, ...ompCommand.args, "--list-models"], {
40
+ stdin: "ignore",
41
+ stdout: "pipe",
42
+ stderr: "pipe",
48
43
  });
49
44
 
50
- if (result.status !== 0 || !result.stdout) {
45
+ if (result.exitCode !== 0 || !result.stdout) {
51
46
  cachedModels = [];
52
47
  cacheExpiry = now + CACHE_TTL_MS;
53
48
  return cachedModels;
54
49
  }
55
50
 
56
51
  // Parse output: skip header line, extract provider/model
57
- const lines = result.stdout.trim().split("\n");
52
+ const lines = result.stdout.toString().trim().split("\n");
58
53
  cachedModels = lines
59
54
  .slice(1) // Skip header
60
55
  .map((line) => {
@@ -0,0 +1,24 @@
1
+ import process from "node:process";
2
+
3
+ interface OmpCommand {
4
+ cmd: string;
5
+ args: string[];
6
+ shell: boolean;
7
+ }
8
+
9
+ const DEFAULT_CMD = process.platform === "win32" ? "omp.cmd" : "omp";
10
+ const DEFAULT_SHELL = process.platform === "win32";
11
+
12
+ export function resolveOmpCommand(): OmpCommand {
13
+ const envCmd = process.env.OMP_SUBPROCESS_CMD;
14
+ if (envCmd?.trim()) {
15
+ return { cmd: envCmd, args: [], shell: DEFAULT_SHELL };
16
+ }
17
+
18
+ const entry = process.argv[1];
19
+ if (entry && (entry.endsWith(".ts") || entry.endsWith(".js"))) {
20
+ return { cmd: process.execPath, args: [entry], shell: false };
21
+ }
22
+
23
+ return { cmd: DEFAULT_CMD, args: [], shell: DEFAULT_SHELL };
24
+ }
@@ -10,6 +10,14 @@ import type { Component } from "@oh-my-pi/pi-tui";
10
10
  import { Container, Text } from "@oh-my-pi/pi-tui";
11
11
  import type { Theme } from "../../../modes/interactive/theme/theme";
12
12
  import type { RenderResultOptions } from "../../custom-tools/types";
13
+ import {
14
+ formatBadge,
15
+ formatDuration,
16
+ formatMoreItems,
17
+ formatTokens,
18
+ getStyledStatusIcon,
19
+ truncate,
20
+ } from "../render-utils";
13
21
  import type { ReportFindingDetails, SubmitReviewDetails } from "../review";
14
22
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
15
23
  import type { AgentProgress, SingleResult, TaskParams, TaskToolDetails } from "./types";
@@ -22,63 +30,26 @@ const PRIORITY_LABELS: Record<number, string> = {
22
30
  3: "P3",
23
31
  };
24
32
 
25
- /**
26
- * Format token count for display (e.g., 1.5k, 25k).
27
- */
28
- function formatTokens(tokens: number): string {
29
- if (tokens >= 1000) {
30
- return `${(tokens / 1000).toFixed(1)}k`;
31
- }
32
- return String(tokens);
33
- }
34
-
35
- /**
36
- * Format duration for display.
37
- */
38
- export function formatDuration(ms: number): string {
39
- if (ms < 1000) return `${ms}ms`;
40
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
41
- return `${(ms / 60000).toFixed(1)}m`;
42
- }
43
-
44
- /**
45
- * Truncate text to max length with ellipsis.
46
- */
47
- function truncate(text: string, maxLen: number, ellipsis: string): string {
48
- if (text.length <= maxLen) return text;
49
- const sliceLen = Math.max(0, maxLen - ellipsis.length);
50
- return `${text.slice(0, sliceLen)}${ellipsis}`;
51
- }
52
-
53
33
  /**
54
34
  * Get status icon for agent state.
55
35
  * For running status, uses animated spinner if spinnerFrame is provided.
36
+ * Maps AgentProgress status to styled icon format.
56
37
  */
57
38
  function getStatusIcon(status: AgentProgress["status"], theme: Theme, spinnerFrame?: number): string {
58
39
  switch (status) {
59
40
  case "pending":
60
- return theme.status.pending;
61
- case "running": {
62
- // Use animated spinner if frame is provided, otherwise static icon
63
- if (spinnerFrame === undefined) return theme.status.running;
64
- const frames = theme.spinnerFrames;
65
- return frames[spinnerFrame % frames.length];
66
- }
41
+ return getStyledStatusIcon("pending", theme);
42
+ case "running":
43
+ return getStyledStatusIcon("running", theme, spinnerFrame);
67
44
  case "completed":
68
- return theme.status.success;
45
+ return getStyledStatusIcon("success", theme);
69
46
  case "failed":
70
- return theme.status.error;
47
+ return getStyledStatusIcon("error", theme);
71
48
  case "aborted":
72
- return theme.status.aborted;
49
+ return getStyledStatusIcon("aborted", theme);
73
50
  }
74
51
  }
75
52
 
76
- function formatBadge(label: string, color: "success" | "error" | "warning" | "accent" | "muted", theme: Theme): string {
77
- const left = theme.format.bracketLeft;
78
- const right = theme.format.bracketRight;
79
- return theme.fg(color, `${left}${label}${right}`);
80
- }
81
-
82
53
  function formatFindingSummary(findings: ReportFindingDetails[], theme: Theme): string {
83
54
  if (findings.length === 0) return theme.fg("dim", "Findings: none");
84
55
 
@@ -119,10 +90,7 @@ function renderOutputSection(
119
90
 
120
91
  if (outputLines.length > previewCount) {
121
92
  lines.push(
122
- `${continuePrefix} ${theme.fg(
123
- "dim",
124
- `${theme.format.ellipsis} ${outputLines.length - previewCount} more lines`,
125
- )}`,
93
+ `${continuePrefix} ${theme.fg("dim", formatMoreItems(outputLines.length - previewCount, "line", theme))}`,
126
94
  );
127
95
  }
128
96
 
@@ -133,12 +101,13 @@ function renderOutputSection(
133
101
  * Render the tool call arguments.
134
102
  */
135
103
  export function renderCall(args: TaskParams, theme: Theme): Component {
136
- const label = theme.fg("toolTitle", theme.bold("task"));
104
+ const label = theme.fg("toolTitle", theme.bold("Task"));
137
105
 
138
106
  if (args.tasks.length === 1) {
139
107
  // Single task - show agent and task preview
140
108
  const task = args.tasks[0];
141
- const taskPreview = truncate(task.task, 60, theme.format.ellipsis);
109
+ const summary = task.description?.trim() || task.task;
110
+ const taskPreview = truncate(summary, 60, theme.format.ellipsis);
142
111
  return new Text(`${label} ${theme.fg("accent", task.agent)}: ${theme.fg("muted", taskPreview)}`, 0, 0);
143
112
  }
144
113
 
@@ -178,6 +147,10 @@ function renderAgentProgress(
178
147
  // Main status line - include index for Output tool ID derivation
179
148
  const agentId = `${progress.agent}(${progress.index})`;
180
149
  let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", agentId)}`;
150
+ const description = progress.description?.trim();
151
+ if (description) {
152
+ statusLine += ` ${theme.fg("muted", truncate(description, 40, theme.format.ellipsis))}`;
153
+ }
181
154
 
182
155
  // Only show badge for non-running states (spinner already indicates running)
183
156
  if (progress.status !== "running") {
@@ -193,8 +166,10 @@ function renderAgentProgress(
193
166
  }
194
167
 
195
168
  if (progress.status === "running") {
196
- const taskPreview = truncate(progress.task, 40, theme.format.ellipsis);
197
- statusLine += ` ${theme.fg("muted", taskPreview)}`;
169
+ if (!description) {
170
+ const taskPreview = truncate(progress.task, 40, theme.format.ellipsis);
171
+ statusLine += ` ${theme.fg("muted", taskPreview)}`;
172
+ }
198
173
  statusLine += `${theme.sep.dot}${theme.fg("dim", `${progress.toolCount} tools`)}`;
199
174
  if (progress.tokens > 0) {
200
175
  statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatTokens(progress.tokens)} tokens`)}`;
@@ -235,9 +210,7 @@ function renderAgentProgress(
235
210
  }
236
211
  }
237
212
  if (dataArray.length > 3) {
238
- lines.push(
239
- `${continuePrefix}${theme.fg("dim", `${theme.format.ellipsis} ${dataArray.length - 3} more`)}`,
240
- );
213
+ lines.push(`${continuePrefix}${theme.fg("dim", formatMoreItems(dataArray.length - 3, "item", theme))}`);
241
214
  }
242
215
  }
243
216
  }
@@ -337,9 +310,7 @@ function renderFindings(
337
310
  }
338
311
 
339
312
  if (!expanded && findings.length > 3) {
340
- lines.push(
341
- `${continuePrefix}${theme.fg("dim", `${theme.format.ellipsis} ${findings.length - 3} more findings`)}`,
342
- );
313
+ lines.push(`${continuePrefix}${theme.fg("dim", formatMoreItems(findings.length - 3, "finding", theme))}`);
343
314
  }
344
315
 
345
316
  return lines;
@@ -364,6 +335,10 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
364
335
  // Main status line - include index for Output tool ID derivation
365
336
  const agentId = `${result.agent}(${result.index})`;
366
337
  let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", agentId)} ${formatBadge(statusText, iconColor, theme)}`;
338
+ const description = result.description?.trim();
339
+ if (description) {
340
+ statusLine += ` ${theme.fg("muted", truncate(description, 40, theme.format.ellipsis))}`;
341
+ }
367
342
  if (result.tokens > 0) {
368
343
  statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatTokens(result.tokens)} tokens`)}`;
369
344
  }
@@ -479,11 +454,11 @@ export function renderResult(
479
454
  let summary = `\n${theme.fg("dim", "Total:")} `;
480
455
  if (abortedCount > 0) {
481
456
  summary += theme.fg("error", `${abortedCount} aborted`);
482
- if (successCount > 0 || failCount > 0) summary += ", ";
457
+ if (successCount > 0 || failCount > 0) summary += theme.sep.dot;
483
458
  }
484
459
  if (successCount > 0) {
485
460
  summary += theme.fg("success", `${successCount} succeeded`);
486
- if (failCount > 0) summary += ", ";
461
+ if (failCount > 0) summary += theme.sep.dot;
487
462
  }
488
463
  if (failCount > 0) {
489
464
  summary += theme.fg("error", `${failCount} failed`);
@@ -32,6 +32,7 @@ export const OMP_SPAWNS_ENV = "OMP_SPAWNS";
32
32
  export const taskItemSchema = Type.Object({
33
33
  agent: Type.String({ description: "Agent name" }),
34
34
  task: Type.String({ description: "Task description for the agent" }),
35
+ description: Type.Optional(Type.String({ description: "Short description for UI display" })),
35
36
  model: Type.Optional(Type.String({ description: "Model override for this task" })),
36
37
  });
37
38
 
@@ -92,6 +93,7 @@ export interface AgentProgress {
92
93
  agentSource: AgentSource;
93
94
  status: "pending" | "running" | "completed" | "failed" | "aborted";
94
95
  task: string;
96
+ description?: string;
95
97
  currentTool?: string;
96
98
  currentToolArgs?: string;
97
99
  currentToolStartMs?: number;
@@ -111,6 +113,7 @@ export interface SingleResult {
111
113
  agent: string;
112
114
  agentSource: AgentSource;
113
115
  task: string;
116
+ description?: string;
114
117
  exitCode: number;
115
118
  output: string;
116
119
  stderr: string;