@oh-my-pi/pi-coding-agent 3.0.1337 → 3.3.1337

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,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [3.3.1337] - 2026-01-03
6
+
7
+ ### Changed
8
+
9
+ - Improved `/status` command output formatting to use consistent column alignment across all sections
10
+ - Updated version update notification to suggest `omp update` instead of manual npm install command
11
+
12
+ ## [3.1.1337] - 2026-01-03
13
+ ### Added
14
+
15
+ - Added `spawns` frontmatter field for agent definitions to control which sub-agents can be spawned
16
+ - Added spawn restriction enforcement preventing agents from spawning unauthorized sub-agents
17
+
18
+ ### Fixed
19
+
20
+ - Fixed duplicate skill loading when the same SKILL.md file was discovered through multiple paths
21
+
5
22
  ## [3.0.1337] - 2026-01-03
6
23
 
7
24
  ### Added
@@ -775,7 +775,7 @@ Update argument parsing:
775
775
  result.noHooks = true;
776
776
  ```
777
777
 
778
- Add subcommand handling for `pi install`, `pi remove`, `pi update`.
778
+ Add subcommand handling for `omp install`, `omp remove`, `omp update`.
779
779
 
780
780
  ### `src/core/hooks/loader.ts`
781
781
 
@@ -949,7 +949,7 @@ if (settings.skills?.customDirectories) {
949
949
  5. **Phase 5: CLI updates**
950
950
  - Add new flags to args.ts
951
951
  - Update help text
952
- - Add `pi install`, `pi remove`, `pi update` subcommands
952
+ - Add `omp install`, `omp remove`, `omp update` subcommands
953
953
 
954
954
  6. **Phase 6: Integration**
955
955
  - Update sdk.ts
package/docs/sdk.md CHANGED
@@ -54,7 +54,7 @@ The main factory function. Creates an `AgentSession` with configurable options.
54
54
 
55
55
  **Philosophy:** "Omit to discover, provide to override."
56
56
 
57
- - Omit an option → pi discovers/loads from standard locations
57
+ - Omit an option → omp discovers/loads from standard locations
58
58
  - Provide an option → your value is used, discovery skipped for that option
59
59
 
60
60
  ```typescript
@@ -405,7 +405,7 @@ const { session } = await createAgentSession({
405
405
 
406
406
  **When you don't need factories:**
407
407
 
408
- - If you omit `tools`, pi automatically creates them with the correct `cwd`
408
+ - If you omit `tools`, omp automatically creates them with the correct `cwd`
409
409
  - If you use `process.cwd()` as your `cwd`, the pre-built instances work fine
410
410
 
411
411
  **When you must use factories:**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "3.0.1337",
3
+ "version": "3.3.1337",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,9 +39,9 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-agent-core": "3.0.1337",
43
- "@oh-my-pi/pi-ai": "3.0.1337",
44
- "@oh-my-pi/pi-tui": "3.0.1337",
42
+ "@oh-my-pi/pi-agent-core": "3.3.1337",
43
+ "@oh-my-pi/pi-ai": "3.3.1337",
44
+ "@oh-my-pi/pi-tui": "3.3.1337",
45
45
  "@sinclair/typebox": "^0.34.46",
46
46
  "ajv": "^8.17.1",
47
47
  "chalk": "^5.5.0",
@@ -46,6 +46,30 @@ export interface LoadSkillsFromDirOptions {
46
46
  export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {
47
47
  const skills: Skill[] = [];
48
48
  const warnings: SkillWarning[] = [];
49
+ const seenPaths = new Set<string>();
50
+
51
+ function addSkill(skillFile: string, skillDir: string, dirName: string) {
52
+ if (seenPaths.has(skillFile)) return;
53
+ try {
54
+ const content = readFileSync(skillFile, "utf-8");
55
+ const { frontmatter } = parseFrontmatter(content);
56
+ const name = (frontmatter.name as string) || dirName;
57
+ const description = frontmatter.description as string;
58
+
59
+ if (description) {
60
+ seenPaths.add(skillFile);
61
+ skills.push({
62
+ name,
63
+ description,
64
+ filePath: skillFile,
65
+ baseDir: skillDir,
66
+ source: options.source,
67
+ });
68
+ }
69
+ } catch {
70
+ // Skip invalid skills
71
+ }
72
+ }
49
73
 
50
74
  function scanDir(dir: string) {
51
75
  try {
@@ -59,45 +83,14 @@ export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkills
59
83
  try {
60
84
  const stat = statSync(skillFile);
61
85
  if (stat.isFile()) {
62
- const content = readFileSync(skillFile, "utf-8");
63
- const { frontmatter } = parseFrontmatter(content);
64
- const name = (frontmatter.name as string) || entry.name;
65
- const description = frontmatter.description as string;
66
-
67
- if (description) {
68
- skills.push({
69
- name,
70
- description,
71
- filePath: skillFile,
72
- baseDir: fullPath,
73
- source: options.source,
74
- });
75
- }
86
+ addSkill(skillFile, fullPath, entry.name);
76
87
  }
77
88
  } catch {
78
- // Skip invalid skills
89
+ // No SKILL.md in this directory
79
90
  }
80
-
81
91
  scanDir(fullPath);
82
92
  } else if (entry.isFile() && entry.name === "SKILL.md") {
83
- try {
84
- const content = readFileSync(fullPath, "utf-8");
85
- const { frontmatter } = parseFrontmatter(content);
86
- const name = (frontmatter.name as string) || basename(dir);
87
- const description = frontmatter.description as string;
88
-
89
- if (description) {
90
- skills.push({
91
- name,
92
- description,
93
- filePath: fullPath,
94
- baseDir: dir,
95
- source: options.source,
96
- });
97
- }
98
- } catch {
99
- // Skip invalid skills
100
- }
93
+ addSkill(fullPath, dir, basename(dir));
101
94
  }
102
95
  }
103
96
  } catch (err) {
@@ -117,6 +110,30 @@ export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkills
117
110
  function scanDirectoryForSkills(dir: string): LoadSkillsResult {
118
111
  const skills: Skill[] = [];
119
112
  const warnings: SkillWarning[] = [];
113
+ const seenPaths = new Set<string>();
114
+
115
+ function addSkill(skillFile: string, skillDir: string, dirName: string) {
116
+ if (seenPaths.has(skillFile)) return;
117
+ try {
118
+ const content = readFileSync(skillFile, "utf-8");
119
+ const { frontmatter } = parseFrontmatter(content);
120
+ const name = (frontmatter.name as string) || dirName;
121
+ const description = frontmatter.description as string;
122
+
123
+ if (description) {
124
+ seenPaths.add(skillFile);
125
+ skills.push({
126
+ name,
127
+ description,
128
+ filePath: skillFile,
129
+ baseDir: skillDir,
130
+ source: "custom",
131
+ });
132
+ }
133
+ } catch {
134
+ // Skip invalid skills
135
+ }
136
+ }
120
137
 
121
138
  function scanDir(currentDir: string) {
122
139
  try {
@@ -130,45 +147,14 @@ function scanDirectoryForSkills(dir: string): LoadSkillsResult {
130
147
  try {
131
148
  const stat = statSync(skillFile);
132
149
  if (stat.isFile()) {
133
- const content = readFileSync(skillFile, "utf-8");
134
- const { frontmatter } = parseFrontmatter(content);
135
- const name = (frontmatter.name as string) || entry.name;
136
- const description = frontmatter.description as string;
137
-
138
- if (description) {
139
- skills.push({
140
- name,
141
- description,
142
- filePath: skillFile,
143
- baseDir: fullPath,
144
- source: "custom",
145
- });
146
- }
150
+ addSkill(skillFile, fullPath, entry.name);
147
151
  }
148
152
  } catch {
149
- // Skip invalid skills
153
+ // No SKILL.md in this directory
150
154
  }
151
-
152
155
  scanDir(fullPath);
153
156
  } else if (entry.isFile() && entry.name === "SKILL.md") {
154
- try {
155
- const content = readFileSync(fullPath, "utf-8");
156
- const { frontmatter } = parseFrontmatter(content);
157
- const name = (frontmatter.name as string) || basename(currentDir);
158
- const description = frontmatter.description as string;
159
-
160
- if (description) {
161
- skills.push({
162
- name,
163
- description,
164
- filePath: fullPath,
165
- baseDir: currentDir,
166
- source: "custom",
167
- });
168
- }
169
- } catch {
170
- // Skip invalid skills
171
- }
157
+ addSkill(fullPath, currentDir, basename(currentDir));
172
158
  }
173
159
  }
174
160
  } catch (err) {
@@ -68,6 +68,26 @@ function parseAgent(fileName: string, content: string, source: AgentSource): Age
68
68
  .map((t) => t.trim())
69
69
  .filter(Boolean);
70
70
 
71
+ // Parse spawns field
72
+ let spawns: string[] | "*" | undefined;
73
+ if (frontmatter.spawns !== undefined) {
74
+ const spawnsRaw = frontmatter.spawns.trim();
75
+ if (spawnsRaw === "*") {
76
+ spawns = "*";
77
+ } else if (spawnsRaw) {
78
+ spawns = spawnsRaw
79
+ .split(",")
80
+ .map((s) => s.trim())
81
+ .filter(Boolean);
82
+ if (spawns.length === 0) spawns = undefined;
83
+ }
84
+ }
85
+
86
+ // Backward compat: infer spawns: "*" when tools includes "task"
87
+ if (spawns === undefined && tools?.includes("task")) {
88
+ spawns = "*";
89
+ }
90
+
71
91
  const recursive =
72
92
  frontmatter.recursive === undefined ? false : frontmatter.recursive === "true" || frontmatter.recursive === "1";
73
93
 
@@ -75,6 +95,7 @@ function parseAgent(fileName: string, content: string, source: AgentSource): Age
75
95
  name: frontmatter.name,
76
96
  description: frontmatter.description,
77
97
  tools: tools && tools.length > 0 ? tools : undefined,
98
+ spawns,
78
99
  model: frontmatter.model,
79
100
  recursive,
80
101
  systemPrompt: body,
@@ -1,7 +1,8 @@
1
1
  ---
2
2
  name: reviewer
3
3
  description: Code review specialist for quality and security analysis
4
- tools: read, grep, find, ls, bash, task, report_finding, submit_review
4
+ tools: read, grep, find, ls, bash, report_finding, submit_review
5
+ spawns: explore
5
6
  model: pi/slow, gpt-5.2-codex, gpt-5.2, codex, gpt
6
7
  ---
7
8
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: task
3
3
  description: General-purpose subagent with full capabilities for delegated multi-step tasks
4
+ spawns: explore
4
5
  model: default
5
6
  ---
6
7
 
@@ -106,6 +106,26 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentDefinition[]
106
106
  .map((t) => t.trim())
107
107
  .filter(Boolean);
108
108
 
109
+ // Parse spawns field
110
+ let spawns: string[] | "*" | undefined;
111
+ if (frontmatter.spawns !== undefined) {
112
+ const spawnsRaw = frontmatter.spawns.trim();
113
+ if (spawnsRaw === "*") {
114
+ spawns = "*";
115
+ } else if (spawnsRaw) {
116
+ spawns = spawnsRaw
117
+ .split(",")
118
+ .map((s) => s.trim())
119
+ .filter(Boolean);
120
+ if (spawns.length === 0) spawns = undefined;
121
+ }
122
+ }
123
+
124
+ // Backward compat: infer spawns: "*" when tools includes "task"
125
+ if (spawns === undefined && tools?.includes("task")) {
126
+ spawns = "*";
127
+ }
128
+
109
129
  const recursive =
110
130
  frontmatter.recursive === undefined
111
131
  ? undefined
@@ -115,6 +135,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentDefinition[]
115
135
  name: frontmatter.name,
116
136
  description: frontmatter.description,
117
137
  tools: tools && tools.length > 0 ? tools : undefined,
138
+ spawns,
118
139
  model: frontmatter.model,
119
140
  recursive,
120
141
  systemPrompt: body,
@@ -19,6 +19,7 @@ import {
19
19
  MAX_OUTPUT_BYTES,
20
20
  MAX_OUTPUT_LINES,
21
21
  OMP_BLOCKED_AGENT_ENV,
22
+ OMP_SPAWNS_ENV,
22
23
  type SingleResult,
23
24
  } from "./types";
24
25
 
@@ -193,7 +194,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
193
194
 
194
195
  // Add tools if specified
195
196
  if (agent.tools && agent.tools.length > 0) {
196
- args.push("--tools", agent.tools.join(","));
197
+ let toolList = agent.tools;
198
+ // Auto-include task tool if spawns defined but task not in tools
199
+ if (agent.spawns !== undefined && !toolList.includes("task")) {
200
+ toolList = [...toolList, "task"];
201
+ }
202
+ args.push("--tools", toolList.join(","));
197
203
  }
198
204
 
199
205
  // Resolve and add model
@@ -220,6 +226,15 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
220
226
  env[OMP_BLOCKED_AGENT_ENV] = agent.name;
221
227
  }
222
228
 
229
+ // Propagate spawn restrictions to subprocess
230
+ if (agent.spawns === undefined) {
231
+ env[OMP_SPAWNS_ENV] = ""; // No spawns = deny all
232
+ } else if (agent.spawns === "*") {
233
+ env[OMP_SPAWNS_ENV] = "*";
234
+ } else {
235
+ env[OMP_SPAWNS_ENV] = agent.spawns.join(",");
236
+ }
237
+
223
238
  // Spawn subprocess
224
239
  const proc = spawn(OMP_CMD, args, {
225
240
  cwd,
@@ -27,6 +27,7 @@ import {
27
27
  MAX_PARALLEL_TASKS,
28
28
  OMP_BLOCKED_AGENT_ENV,
29
29
  OMP_NO_SUBAGENTS_ENV,
30
+ OMP_SPAWNS_ENV,
30
31
  type TaskToolDetails,
31
32
  taskSchema,
32
33
  } from "./types";
@@ -321,6 +322,30 @@ export function createTaskTool(
321
322
  }
322
323
  }
323
324
 
325
+ // Check spawn restrictions from parent
326
+ const parentSpawns = process.env[OMP_SPAWNS_ENV];
327
+ const isSpawnAllowed = (agentName: string): boolean => {
328
+ if (parentSpawns === undefined) return true; // Root = allow all
329
+ if (parentSpawns === "") return false; // Empty = deny all
330
+ if (parentSpawns === "*") return true; // Wildcard = allow all
331
+ const allowed = new Set(parentSpawns.split(",").map((s) => s.trim()));
332
+ return allowed.has(agentName);
333
+ };
334
+
335
+ for (const task of tasks) {
336
+ if (!isSpawnAllowed(task.agent)) {
337
+ const allowed = parentSpawns === "" ? "none (spawns disabled for this agent)" : parentSpawns;
338
+ return {
339
+ content: [{ type: "text", text: `Cannot spawn '${task.agent}'. Allowed: ${allowed}` }],
340
+ details: {
341
+ projectAgentsDir,
342
+ results: [],
343
+ totalDurationMs: Date.now() - startTime,
344
+ },
345
+ };
346
+ }
347
+ }
348
+
324
349
  // Initialize progress for all tasks
325
350
  for (let i = 0; i < tasks.length; i++) {
326
351
  const agentCfg = getAgent(agents, tasks[i].agent);
@@ -33,6 +33,9 @@ export const OMP_NO_SUBAGENTS_ENV = "OMP_NO_SUBAGENTS";
33
33
  /** Environment variable containing blocked agent name (self-recursion prevention) */
34
34
  export const OMP_BLOCKED_AGENT_ENV = "OMP_BLOCKED_AGENT";
35
35
 
36
+ /** Environment variable containing allowed spawn list (propagated to subprocesses) */
37
+ export const OMP_SPAWNS_ENV = "OMP_SPAWNS";
38
+
36
39
  /** Task tool parameters */
37
40
  export const taskSchema = Type.Object({
38
41
  context: Type.Optional(Type.String({ description: "Shared context prepended to all task prompts" })),
@@ -74,6 +77,7 @@ export interface AgentDefinition {
74
77
  description: string;
75
78
  systemPrompt: string;
76
79
  tools?: string[];
80
+ spawns?: string[] | "*";
77
81
  model?: string;
78
82
  recursive?: boolean;
79
83
  source: AgentSource;
@@ -1617,7 +1617,7 @@ export class InteractiveMode {
1617
1617
  theme.bold(theme.fg("warning", "Update Available")) +
1618
1618
  "\n" +
1619
1619
  theme.fg("muted", `New version ${newVersion} is available. Run: `) +
1620
- theme.fg("accent", "npm install -g @oh-my-pi/pi-coding-agent"),
1620
+ theme.fg("accent", "omp update"),
1621
1621
  1,
1622
1622
  0,
1623
1623
  ),
@@ -2391,14 +2391,26 @@ export class InteractiveMode {
2391
2391
  }
2392
2392
 
2393
2393
  private handleStatusCommand(): void {
2394
- const sections: string[] = [];
2395
-
2396
2394
  type StatusSource =
2397
2395
  | { provider: string; level: string }
2398
2396
  | { mcpServer: string; provider?: string }
2399
2397
  | "builtin"
2400
2398
  | "unknown";
2401
2399
 
2400
+ type StatusLine = {
2401
+ name: string;
2402
+ sourceText: string;
2403
+ nameWithSource: string;
2404
+ desc?: string;
2405
+ };
2406
+
2407
+ type LineSection = {
2408
+ title: string;
2409
+ lines: StatusLine[];
2410
+ };
2411
+
2412
+ type Section = { kind: "lines"; section: LineSection } | { kind: "text"; text: string };
2413
+
2402
2414
  const capitalize = (value: string): string => value.charAt(0).toUpperCase() + value.slice(1);
2403
2415
 
2404
2416
  const resolveSourceText = (source: StatusSource): string => {
@@ -2414,138 +2426,157 @@ export class InteractiveMode {
2414
2426
 
2415
2427
  const renderSourceText = (text: string): string => text.replace(/\bvia\b/, theme.italic("via"));
2416
2428
 
2417
- const truncateText = (text: string, maxLen: number): string => {
2418
- if (text.length <= maxLen) return text;
2419
- if (maxLen <= 3) return text.slice(0, Math.max(0, maxLen));
2420
- return `${text.slice(0, maxLen - 3)}...`;
2429
+ const truncateText = (text: string, maxWidth: number): string => {
2430
+ const textWidth = visibleWidth(text);
2431
+ if (textWidth <= maxWidth) return text;
2432
+ if (maxWidth <= 3) {
2433
+ let acc = "";
2434
+ let width = 0;
2435
+ for (const char of text) {
2436
+ const charWidth = visibleWidth(char);
2437
+ if (width + charWidth > maxWidth) break;
2438
+ width += charWidth;
2439
+ acc += char;
2440
+ }
2441
+ return acc;
2442
+ }
2443
+ const targetWidth = maxWidth - 3;
2444
+ let acc = "";
2445
+ let width = 0;
2446
+ for (const char of text) {
2447
+ const charWidth = visibleWidth(char);
2448
+ if (width + charWidth > targetWidth) break;
2449
+ width += charWidth;
2450
+ acc += char;
2451
+ }
2452
+ return `${acc}...`;
2421
2453
  };
2422
2454
 
2423
- // Helper to format a section with consistent column alignment
2424
- const formatSection = <T>(
2455
+ const buildLineSection = <T>(
2425
2456
  title: string,
2426
2457
  items: readonly T[],
2427
2458
  getName: (item: T) => string,
2428
2459
  getDesc: (item: T) => string | undefined,
2429
2460
  getSource: (item: T) => StatusSource,
2430
- ): string => {
2431
- if (items.length === 0) return "";
2461
+ ): LineSection | null => {
2462
+ if (items.length === 0) return null;
2432
2463
 
2433
- const lineItems = items.map((item) => {
2464
+ const lines = items.map((item) => {
2434
2465
  const name = getName(item);
2435
- const desc = getDesc(item);
2466
+ const desc = getDesc(item)?.trim();
2436
2467
  const sourceText = resolveSourceText(getSource(item));
2437
2468
  const nameWithSource = sourceText ? `${name} ${sourceText}` : name;
2438
2469
  return { name, sourceText, nameWithSource, desc };
2439
2470
  });
2440
2471
 
2441
- const maxNameWidth = Math.min(60, Math.max(...lineItems.map((line) => line.nameWithSource.length)));
2442
- const formattedLines = lineItems.map((line) => {
2472
+ return { title, lines };
2473
+ };
2474
+
2475
+ const renderLineSection = (section: LineSection, maxNameWidth: number): string => {
2476
+ const formattedLines = section.lines.map((line) => {
2443
2477
  let nameText = line.name;
2444
2478
  let sourceText = line.sourceText;
2445
2479
 
2446
2480
  if (sourceText) {
2447
- let availableForName = maxNameWidth - sourceText.length - 1;
2448
- if (availableForName < 1) {
2449
- sourceText = truncateText(sourceText, Math.max(0, maxNameWidth - 4));
2450
- availableForName = maxNameWidth - sourceText.length - 1;
2451
- }
2452
- nameText = truncateText(nameText, Math.max(1, availableForName));
2453
- } else {
2454
- nameText = truncateText(nameText, maxNameWidth);
2481
+ const maxSourceWidth = Math.max(0, maxNameWidth - 2);
2482
+ sourceText = truncateText(sourceText, maxSourceWidth);
2455
2483
  }
2484
+ const sourceWidth = sourceText ? visibleWidth(sourceText) : 0;
2485
+ const availableForName = sourceText ? Math.max(1, maxNameWidth - sourceWidth - 1) : maxNameWidth;
2486
+ nameText = truncateText(nameText, availableForName);
2456
2487
 
2457
2488
  const nameWithSourcePlain = sourceText ? `${nameText} ${sourceText}` : nameText;
2458
2489
  const sourceRendered = sourceText ? renderSourceText(sourceText) : "";
2459
2490
  const nameRendered = sourceText ? `${theme.bold(nameText)} ${sourceRendered}` : theme.bold(nameText);
2460
- const pad = Math.max(0, maxNameWidth - nameWithSourcePlain.length);
2461
- const desc = line.desc?.trim();
2462
- const descPart = desc ? ` ${theme.fg("dim", desc.slice(0, 50) + (desc.length > 50 ? "..." : ""))}` : "";
2491
+ const pad = Math.max(0, maxNameWidth - visibleWidth(nameWithSourcePlain));
2492
+ const desc = line.desc;
2493
+ const descPart = desc
2494
+ ? ` ${theme.fg("dim", desc.slice(0, 50) + (desc.length > 50 ? "..." : ""))}`
2495
+ : "";
2463
2496
  return ` ${nameRendered}${" ".repeat(pad)}${descPart}`;
2464
2497
  });
2465
2498
 
2466
- return `${theme.bold(theme.fg("accent", title))}\n${formattedLines.join("\n")}`;
2499
+ return `${theme.bold(theme.fg("accent", section.title))}\n${formattedLines.join("\n")}`;
2500
+ };
2501
+
2502
+ const sections: Section[] = [];
2503
+ const pushLineSection = <T>(
2504
+ title: string,
2505
+ items: readonly T[],
2506
+ getName: (item: T) => string,
2507
+ getDesc: (item: T) => string | undefined,
2508
+ getSource: (item: T) => StatusSource,
2509
+ ): void => {
2510
+ const section = buildLineSection(title, items, getName, getDesc, getSource);
2511
+ if (section) {
2512
+ sections.push({ kind: "lines", section });
2513
+ }
2467
2514
  };
2468
2515
 
2469
2516
  // Loaded context files
2470
2517
  const contextFilesResult = loadSync(contextFileCapability.id, { cwd: process.cwd() });
2471
2518
  const contextFiles = contextFilesResult.items as ContextFile[];
2472
- if (contextFiles.length > 0) {
2473
- sections.push(
2474
- formatSection(
2475
- "Context Files",
2476
- contextFiles,
2477
- (f) => basename(f.path),
2478
- () => undefined,
2479
- (f) => ({ provider: f._source.providerName, level: f.level }),
2480
- ),
2481
- );
2482
- }
2519
+ pushLineSection(
2520
+ "Context Files",
2521
+ contextFiles,
2522
+ (f) => basename(f.path),
2523
+ () => undefined,
2524
+ (f) => ({ provider: f._source.providerName, level: f.level }),
2525
+ );
2483
2526
 
2484
2527
  // Loaded skills
2485
2528
  const skillsSettings = this.session.skillsSettings;
2486
2529
  if (skillsSettings?.enabled !== false) {
2487
2530
  const { skills, warnings: skillWarnings } = loadSkills(skillsSettings ?? {});
2488
- if (skills.length > 0) {
2489
- sections.push(
2490
- formatSection(
2491
- "Skills",
2492
- skills,
2493
- (s) => s.name,
2494
- (s) => s.description,
2495
- (s) => (s._source ? { provider: s._source.providerName, level: s._source.level } : "unknown"),
2496
- ),
2497
- );
2498
- }
2531
+ pushLineSection(
2532
+ "Skills",
2533
+ skills,
2534
+ (s) => s.name,
2535
+ (s) => s.description,
2536
+ (s) => (s._source ? { provider: s._source.providerName, level: s._source.level } : "unknown"),
2537
+ );
2499
2538
  if (skillWarnings.length > 0) {
2500
2539
  sections.push(
2501
- theme.bold(theme.fg("warning", "Skill Warnings")) +
2502
- "\n" +
2503
- skillWarnings.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)).join("\n"),
2540
+ {
2541
+ kind: "text",
2542
+ text:
2543
+ theme.bold(theme.fg("warning", "Skill Warnings")) +
2544
+ "\n" +
2545
+ skillWarnings.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)).join("\n"),
2546
+ },
2504
2547
  );
2505
2548
  }
2506
2549
  }
2507
2550
 
2508
2551
  // Loaded rules
2509
2552
  const rulesResult = loadSync<Rule>(ruleCapability.id, { cwd: process.cwd() });
2510
- if (rulesResult.items.length > 0) {
2511
- sections.push(
2512
- formatSection(
2513
- "Rules",
2514
- rulesResult.items,
2515
- (r) => r.name,
2516
- (r) => r.description,
2517
- (r) => ({ provider: r._source.providerName, level: r._source.level }),
2518
- ),
2519
- );
2520
- }
2553
+ pushLineSection(
2554
+ "Rules",
2555
+ rulesResult.items,
2556
+ (r) => r.name,
2557
+ (r) => r.description,
2558
+ (r) => ({ provider: r._source.providerName, level: r._source.level }),
2559
+ );
2521
2560
 
2522
2561
  // Loaded prompts
2523
2562
  const promptsResult = loadSync<Prompt>(promptCapability.id, { cwd: process.cwd() });
2524
- if (promptsResult.items.length > 0) {
2525
- sections.push(
2526
- formatSection(
2527
- "Prompts",
2528
- promptsResult.items,
2529
- (p) => p.name,
2530
- () => undefined,
2531
- (p) => ({ provider: p._source.providerName, level: p._source.level }),
2532
- ),
2533
- );
2534
- }
2563
+ pushLineSection(
2564
+ "Prompts",
2565
+ promptsResult.items,
2566
+ (p) => p.name,
2567
+ () => undefined,
2568
+ (p) => ({ provider: p._source.providerName, level: p._source.level }),
2569
+ );
2535
2570
 
2536
2571
  // Loaded instructions
2537
2572
  const instructionsResult = loadSync<Instruction>(instructionCapability.id, { cwd: process.cwd() });
2538
- if (instructionsResult.items.length > 0) {
2539
- sections.push(
2540
- formatSection(
2541
- "Instructions",
2542
- instructionsResult.items,
2543
- (i) => i.name,
2544
- (i) => (i.applyTo ? `applies to: ${i.applyTo}` : undefined),
2545
- (i) => ({ provider: i._source.providerName, level: i._source.level }),
2546
- ),
2547
- );
2548
- }
2573
+ pushLineSection(
2574
+ "Instructions",
2575
+ instructionsResult.items,
2576
+ (i) => i.name,
2577
+ (i) => (i.applyTo ? `applies to: ${i.applyTo}` : undefined),
2578
+ (i) => ({ provider: i._source.providerName, level: i._source.level }),
2579
+ );
2549
2580
 
2550
2581
  // Loaded custom tools - split MCP from non-MCP
2551
2582
  if (this.customTools.size > 0) {
@@ -2555,55 +2586,47 @@ export class InteractiveMode {
2555
2586
 
2556
2587
  // MCP Tools section
2557
2588
  if (mcpTools.length > 0) {
2558
- sections.push(
2559
- formatSection(
2560
- "MCP Tools",
2561
- mcpTools,
2562
- (ct) => ct.tool.label || ct.tool.name,
2563
- () => undefined,
2564
- (ct) => {
2565
- const match = ct.path.match(/^mcp:(.+?) via (.+)$/);
2566
- if (match) {
2567
- const [, serverName, providerName] = match;
2568
- return { mcpServer: serverName, provider: providerName };
2569
- }
2570
- return ct.path.startsWith("mcp:") ? { mcpServer: ct.path.slice(4) } : "unknown";
2571
- },
2572
- ),
2589
+ pushLineSection(
2590
+ "MCP Tools",
2591
+ mcpTools,
2592
+ (ct) => ct.tool.label || ct.tool.name,
2593
+ () => undefined,
2594
+ (ct) => {
2595
+ const match = ct.path.match(/^mcp:(.+?) via (.+)$/);
2596
+ if (match) {
2597
+ const [, serverName, providerName] = match;
2598
+ return { mcpServer: serverName, provider: providerName };
2599
+ }
2600
+ return ct.path.startsWith("mcp:") ? { mcpServer: ct.path.slice(4) } : "unknown";
2601
+ },
2573
2602
  );
2574
2603
  }
2575
2604
 
2576
2605
  // Custom Tools section
2577
2606
  if (customTools.length > 0) {
2578
- sections.push(
2579
- formatSection(
2580
- "Custom Tools",
2581
- customTools,
2582
- (ct) => ct.tool.label || ct.tool.name,
2583
- (ct) => ct.tool.description,
2584
- (ct) => {
2585
- if (ct.source?.provider === "builtin") return "builtin";
2586
- if (ct.path === "<exa>") return "builtin";
2587
- return ct.source ? { provider: ct.source.providerName, level: ct.source.level } : "unknown";
2588
- },
2589
- ),
2607
+ pushLineSection(
2608
+ "Custom Tools",
2609
+ customTools,
2610
+ (ct) => ct.tool.label || ct.tool.name,
2611
+ (ct) => ct.tool.description,
2612
+ (ct) => {
2613
+ if (ct.source?.provider === "builtin") return "builtin";
2614
+ if (ct.path === "<exa>") return "builtin";
2615
+ return ct.source ? { provider: ct.source.providerName, level: ct.source.level } : "unknown";
2616
+ },
2590
2617
  );
2591
2618
  }
2592
2619
  }
2593
2620
 
2594
2621
  // Loaded slash commands (file-based)
2595
2622
  const fileCommands = this.session.fileCommands;
2596
- if (fileCommands.length > 0) {
2597
- sections.push(
2598
- formatSection(
2599
- "Slash Commands",
2600
- fileCommands,
2601
- (cmd) => `/${cmd.name}`,
2602
- (cmd) => cmd.description,
2603
- (cmd) => (cmd._source ? { provider: cmd._source.providerName, level: cmd._source.level } : "unknown"),
2604
- ),
2605
- );
2606
- }
2623
+ pushLineSection(
2624
+ "Slash Commands",
2625
+ fileCommands,
2626
+ (cmd) => `/${cmd.name}`,
2627
+ (cmd) => cmd.description,
2628
+ (cmd) => (cmd._source ? { provider: cmd._source.providerName, level: cmd._source.level } : "unknown"),
2629
+ );
2607
2630
 
2608
2631
  // Loaded hooks
2609
2632
  const hookRunner = this.session.hookRunner;
@@ -2611,12 +2634,30 @@ export class InteractiveMode {
2611
2634
  const hookPaths = hookRunner.getHookPaths();
2612
2635
  if (hookPaths.length > 0) {
2613
2636
  sections.push(
2614
- `${theme.bold(theme.fg("accent", "Hooks"))}\n${hookPaths.map((p) => ` ${theme.bold(basename(p))} ${theme.fg("dim", "hook")}`).join("\n")}`,
2637
+ {
2638
+ kind: "text",
2639
+ text:
2640
+ `${theme.bold(theme.fg("accent", "Hooks"))}\n` +
2641
+ hookPaths.map((p) => ` ${theme.bold(basename(p))} ${theme.fg("dim", "hook")}`).join("\n"),
2642
+ },
2615
2643
  );
2616
2644
  }
2617
2645
  }
2618
2646
 
2619
- if (sections.length === 0) {
2647
+ const lineSections = sections.filter((section): section is { kind: "lines"; section: LineSection } => {
2648
+ return section.kind === "lines";
2649
+ });
2650
+ const allLines = lineSections.flatMap((section) => section.section.lines);
2651
+ const maxNameWidth = allLines.length
2652
+ ? Math.min(60, Math.max(...allLines.map((line) => visibleWidth(line.nameWithSource))))
2653
+ : 0;
2654
+ const renderedSections = sections
2655
+ .map((section) =>
2656
+ section.kind === "lines" ? renderLineSection(section.section, maxNameWidth) : section.text,
2657
+ )
2658
+ .filter((section) => section.length > 0);
2659
+
2660
+ if (renderedSections.length === 0) {
2620
2661
  this.chatContainer.addChild(new Spacer(1));
2621
2662
  this.chatContainer.addChild(new Text(theme.fg("muted", "No extensions loaded."), 1, 0));
2622
2663
  } else {
@@ -2624,7 +2665,7 @@ export class InteractiveMode {
2624
2665
  this.chatContainer.addChild(new DynamicBorder());
2625
2666
  this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Loaded Extensions")), 1, 0));
2626
2667
  this.chatContainer.addChild(new Spacer(1));
2627
- for (const section of sections) {
2668
+ for (const section of renderedSections) {
2628
2669
  this.chatContainer.addChild(new Text(section, 1, 0));
2629
2670
  this.chatContainer.addChild(new Spacer(1));
2630
2671
  }
@@ -2,8 +2,8 @@
2
2
  * Print mode (single-shot): Send prompts, output result, exit.
3
3
  *
4
4
  * Used for:
5
- * - `pi -p "prompt"` - text output
6
- * - `pi --mode json "prompt"` - JSON event stream
5
+ * - `omp -p "prompt"` - text output
6
+ * - `omp --mode json "prompt"` - JSON event stream
7
7
  */
8
8
 
9
9
  import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";