@oh-my-pi/pi-coding-agent 4.4.9 → 4.6.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,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [4.6.0] - 2026-01-12
6
+
7
+ ### Added
8
+ - Add `/skill:name` slash commands for quick skill access (toggle via `skills.enableSkillCommands` setting)
9
+ - Add `cwd` to SessionInfo for session list display
10
+ - Add custom summarization instructions option in tree selector
11
+ - Add Alt+Up (dequeue) to restore all queued messages at once
12
+ - Add `shutdownRequested` and `checkShutdownRequested()` for extension-initiated shutdown
13
+
14
+ ### Fixed
15
+ - Component `invalidate()` now properly rebuilds content on theme changes
16
+ - Force full re-render after returning from external editor
17
+
18
+ ## [4.5.0] - 2026-01-12
19
+
5
20
  ## [4.4.9] - 2026-01-12
6
21
 
7
22
  ## [4.4.8] - 2026-01-12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "4.4.9",
3
+ "version": "4.6.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,10 +39,10 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "4.4.9",
43
- "@oh-my-pi/pi-agent-core": "4.4.9",
44
- "@oh-my-pi/pi-git-tool": "4.4.9",
45
- "@oh-my-pi/pi-tui": "4.4.9",
42
+ "@oh-my-pi/pi-ai": "4.6.0",
43
+ "@oh-my-pi/pi-agent-core": "4.6.0",
44
+ "@oh-my-pi/pi-git-tool": "4.6.0",
45
+ "@oh-my-pi/pi-tui": "4.6.0",
46
46
  "@openai/agents": "^0.3.7",
47
47
  "@sinclair/typebox": "^0.34.46",
48
48
  "ajv": "^8.17.1",
@@ -25,7 +25,7 @@ type HandlerFn = (...args: unknown[]) => Promise<unknown>;
25
25
  */
26
26
  export type SendMessageHandler = <T = unknown>(
27
27
  message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
28
- triggerTurn?: boolean,
28
+ options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
29
29
  ) => void;
30
30
 
31
31
  /**
@@ -129,11 +129,14 @@ function createHookAPI(
129
129
  }
130
130
  handlers.get(event)!.push(handler);
131
131
  },
132
- sendMessage<T = unknown>(message: HookMessage<T>, triggerTurn?: boolean): void {
132
+ sendMessage<T = unknown>(
133
+ message: HookMessage<T>,
134
+ options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
135
+ ): void {
133
136
  if (!sendMessageHandler) {
134
137
  throw new Error("sendMessage handler not initialized");
135
138
  }
136
- sendMessageHandler(message, triggerTurn);
139
+ sendMessageHandler(message, options);
137
140
  },
138
141
  appendEntry<T = unknown>(customType: string, data?: T): void {
139
142
  if (!appendEntryHandler) {
@@ -59,6 +59,7 @@ export function wrapToolWithHooks<T>(tool: AgentTool<any, T>, hookRunner: HookRu
59
59
  input: params,
60
60
  content: result.content,
61
61
  details: result.details,
62
+ isError: false,
62
63
  })) as ToolResultEventResult | undefined;
63
64
 
64
65
  // Apply modifications if any
@@ -164,6 +164,8 @@ export interface SessionContext {
164
164
  export interface SessionInfo {
165
165
  path: string;
166
166
  id: string;
167
+ /** Working directory where the session was started. Empty string for old sessions. */
168
+ cwd: string;
167
169
  title?: string;
168
170
  created: Date;
169
171
  modified: Date;
@@ -1707,7 +1709,7 @@ export class SessionManager {
1707
1709
  if (lines.length === 0) continue;
1708
1710
 
1709
1711
  // Check first line for valid session header
1710
- let header: { type: string; id: string; title?: string; timestamp: string } | null = null;
1712
+ let header: { type: string; id: string; cwd?: string; title?: string; timestamp: string } | null = null;
1711
1713
  try {
1712
1714
  const first = JSON.parse(lines[0]);
1713
1715
  if (first.type === "session" && first.id) {
@@ -1753,6 +1755,7 @@ export class SessionManager {
1753
1755
  sessions.push({
1754
1756
  path: file,
1755
1757
  id: header.id,
1758
+ cwd: typeof header.cwd === "string" ? header.cwd : "",
1756
1759
  title: header.title,
1757
1760
  created: new Date(header.timestamp),
1758
1761
  modified: stats.mtime,
@@ -26,6 +26,7 @@ export interface RetrySettings {
26
26
 
27
27
  export interface SkillsSettings {
28
28
  enabled?: boolean; // default: true
29
+ enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands
29
30
  enableCodexUser?: boolean; // default: true
30
31
  enableClaudeUser?: boolean; // default: true
31
32
  enableClaudeProject?: boolean; // default: true
@@ -821,6 +822,7 @@ export class SettingsManager {
821
822
  getSkillsSettings(): Required<SkillsSettings> {
822
823
  return {
823
824
  enabled: this.settings.skills?.enabled ?? true,
825
+ enableSkillCommands: this.settings.skills?.enableSkillCommands ?? true,
824
826
  enableCodexUser: this.settings.skills?.enableCodexUser ?? true,
825
827
  enableClaudeUser: this.settings.skills?.enableClaudeUser ?? true,
826
828
  enableClaudeProject: this.settings.skills?.enableClaudeProject ?? true,
@@ -832,6 +834,10 @@ export class SettingsManager {
832
834
  };
833
835
  }
834
836
 
837
+ getEnableSkillCommands(): boolean {
838
+ return this.settings.skills?.enableSkillCommands ?? true;
839
+ }
840
+
835
841
  getCommandsSettings(): Required<CommandsSettings> {
836
842
  return {
837
843
  enableClaudeUser: this.settings.commands?.enableClaudeUser ?? true,
@@ -1,5 +1,5 @@
1
1
  export { type AskToolDetails, askTool, createAskTool } from "./ask";
2
- export { type BashOperations, type BashToolDetails, createBashTool } from "./bash";
2
+ export { type BashOperations, type BashToolDetails, type BashToolOptions, createBashTool } from "./bash";
3
3
  export { type CalculatorToolDetails, createCalculatorTool } from "./calculator";
4
4
  export { createCompleteTool } from "./complete";
5
5
  export { createEditTool, type EditToolDetails } from "./edit";
@@ -198,6 +198,36 @@ describe("sanitizeSchemaForGoogle", () => {
198
198
  expect(sanitizeSchemaForGoogle(true)).toBe(true);
199
199
  expect(sanitizeSchemaForGoogle(null)).toBe(null);
200
200
  });
201
+
202
+ it("preserves property names that match schema keywords (e.g., 'pattern')", () => {
203
+ const schema = {
204
+ type: "object",
205
+ properties: {
206
+ pattern: { type: "string", description: "The search pattern" },
207
+ format: { type: "string", description: "Output format" },
208
+ },
209
+ required: ["pattern"],
210
+ };
211
+ const sanitized = sanitizeSchemaForGoogle(schema) as Record<string, unknown>;
212
+ const props = sanitized.properties as Record<string, unknown>;
213
+ expect(props.pattern).toEqual({ type: "string", description: "The search pattern" });
214
+ expect(props.format).toEqual({ type: "string", description: "Output format" });
215
+ expect(sanitized.required).toEqual(["pattern"]);
216
+ });
217
+
218
+ it("still strips schema keywords from non-properties contexts", () => {
219
+ const schema = {
220
+ type: "string",
221
+ pattern: "^[a-z]+$",
222
+ format: "email",
223
+ minLength: 1,
224
+ };
225
+ const sanitized = sanitizeSchemaForGoogle(schema) as Record<string, unknown>;
226
+ expect(sanitized.pattern).toBeUndefined();
227
+ expect(sanitized.format).toBeUndefined();
228
+ expect(sanitized.minLength).toBeUndefined();
229
+ expect(sanitized.type).toBe("string");
230
+ });
201
231
  });
202
232
 
203
233
  describe("tool schema validation (post-sanitization)", () => {
@@ -113,7 +113,7 @@ export class HookEditorComponent extends Container {
113
113
  // Ignore cleanup errors
114
114
  }
115
115
  this.tui.start();
116
- this.tui.requestRender();
116
+ this.tui.requestRender(true);
117
117
  }
118
118
  }
119
119
  }
@@ -36,6 +36,11 @@ export class HookMessageComponent extends Container {
36
36
  }
37
37
  }
38
38
 
39
+ override invalidate(): void {
40
+ super.invalidate();
41
+ this.rebuild();
42
+ }
43
+
39
44
  private rebuild(): void {
40
45
  // Remove previous content component
41
46
  if (this.customComponent) {
@@ -476,6 +476,17 @@ export class CommandController {
476
476
  await this.executeCompaction(customInstructions, false);
477
477
  }
478
478
 
479
+ async handleSkillCommand(skillPath: string, args: string): Promise<void> {
480
+ try {
481
+ const content = fs.readFileSync(skillPath, "utf-8");
482
+ const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
483
+ const message = args ? `${body}\n\n---\n\nUser: ${args}` : body;
484
+ await this.ctx.session.prompt(message);
485
+ } catch (err) {
486
+ this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
487
+ }
488
+ }
489
+
479
490
  async executeCompaction(customInstructions?: string, isAuto = false): Promise<void> {
480
491
  if (this.ctx.loadingAnimation) {
481
492
  this.ctx.loadingAnimation.stop();
@@ -150,6 +150,15 @@ export class EventController {
150
150
  if (event.message.role === "user") break;
151
151
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
152
152
  this.ctx.streamingMessage = event.message;
153
+ let errorMessage: string | undefined;
154
+ if (this.ctx.streamingMessage.stopReason === "aborted" && !this.ctx.session.isTtsrAbortPending) {
155
+ const retryAttempt = this.ctx.session.retryAttempt;
156
+ errorMessage =
157
+ retryAttempt > 0
158
+ ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
159
+ : "Operation aborted";
160
+ this.ctx.streamingMessage.errorMessage = errorMessage;
161
+ }
153
162
  if (this.ctx.session.isTtsrAbortPending && this.ctx.streamingMessage.stopReason === "aborted") {
154
163
  const msgWithoutAbort = { ...this.ctx.streamingMessage, stopReason: "stop" as const };
155
164
  this.ctx.streamingComponent.updateContent(msgWithoutAbort);
@@ -162,14 +171,7 @@ export class EventController {
162
171
  this.ctx.streamingMessage.stopReason === "error"
163
172
  ) {
164
173
  if (!this.ctx.session.isTtsrAbortPending) {
165
- let errorMessage: string;
166
- if (this.ctx.streamingMessage.stopReason === "aborted") {
167
- const retryAttempt = this.ctx.session.retryAttempt;
168
- errorMessage =
169
- retryAttempt > 0
170
- ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
171
- : "Operation aborted";
172
- } else {
174
+ if (!errorMessage) {
173
175
  errorMessage = this.ctx.streamingMessage.errorMessage || "Error";
174
176
  }
175
177
  for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
@@ -24,14 +24,7 @@ export class InputController {
24
24
  setupKeyHandlers(): void {
25
25
  this.ctx.editor.onEscape = () => {
26
26
  if (this.ctx.loadingAnimation) {
27
- // Abort and restore queued messages to editor
28
- const queuedMessages = this.ctx.session.clearQueue();
29
- const queuedText = [...queuedMessages.steering, ...queuedMessages.followUp].join("\n\n");
30
- const currentText = this.ctx.editor.getText();
31
- const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n");
32
- this.ctx.editor.setText(combinedText);
33
- this.ctx.updatePendingMessagesDisplay();
34
- this.ctx.agent.abort();
27
+ this.restoreQueuedMessagesToEditor({ abort: true });
35
28
  } else if (this.ctx.session.isBashRunning) {
36
29
  this.ctx.session.abortBash();
37
30
  } else if (this.ctx.isBashMode) {
@@ -241,6 +234,27 @@ export class InputController {
241
234
  return;
242
235
  }
243
236
 
237
+ // Handle skill commands (/skill:name [args])
238
+ if (text.startsWith("/skill:")) {
239
+ const spaceIndex = text.indexOf(" ");
240
+ const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
241
+ const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
242
+ const skillPath = this.ctx.skillCommands?.get(commandName);
243
+ if (skillPath) {
244
+ this.ctx.editor.addToHistory(text);
245
+ this.ctx.editor.setText("");
246
+ try {
247
+ const content = fs.readFileSync(skillPath, "utf-8");
248
+ const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
249
+ const message = args ? `${body}\n\n---\n\nUser: ${args}` : body;
250
+ await this.ctx.session.prompt(message);
251
+ } catch (err) {
252
+ this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
253
+ }
254
+ return;
255
+ }
256
+ }
257
+
244
258
  // Handle bash command (! for normal, !! for excluded from context)
245
259
  if (text.startsWith("!")) {
246
260
  const isExcluded = text.startsWith("!!");
@@ -341,15 +355,33 @@ export class InputController {
341
355
  }
342
356
 
343
357
  handleDequeue(): void {
344
- const message = this.ctx.session.popLastQueuedMessage();
345
- if (!message) return;
358
+ const restored = this.restoreQueuedMessagesToEditor();
359
+ if (restored === 0) {
360
+ this.ctx.showStatus("No queued messages to restore");
361
+ } else {
362
+ this.ctx.showStatus(`Restored ${restored} queued message${restored > 1 ? "s" : ""} to editor`);
363
+ }
364
+ }
346
365
 
347
- // Prepend to existing editor text (if any)
348
- const currentText = this.ctx.editor.getText();
349
- const newText = currentText ? `${message}\n\n${currentText}` : message;
350
- this.ctx.editor.setText(newText);
366
+ restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number {
367
+ const { steering, followUp } = this.ctx.session.clearQueue();
368
+ const allQueued = [...steering, ...followUp];
369
+ if (allQueued.length === 0) {
370
+ this.ctx.updatePendingMessagesDisplay();
371
+ if (options?.abort) {
372
+ this.ctx.agent.abort();
373
+ }
374
+ return 0;
375
+ }
376
+ const queuedText = allQueued.join("\n\n");
377
+ const currentText = options?.currentText ?? this.ctx.editor.getText();
378
+ const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n");
379
+ this.ctx.editor.setText(combinedText);
351
380
  this.ctx.updatePendingMessagesDisplay();
352
- this.ctx.ui.requestRender();
381
+ if (options?.abort) {
382
+ this.ctx.agent.abort();
383
+ }
384
+ return allQueued.length;
353
385
  }
354
386
 
355
387
  handleBackgroundCommand(): void {
@@ -352,16 +352,41 @@ export class SelectorController {
352
352
  return;
353
353
  }
354
354
 
355
- // Ask about summarization (or skip if disabled in settings)
355
+ // Ask about summarization
356
356
  done(); // Close selector first
357
357
 
358
+ // Loop until user makes a complete choice or cancels to tree
359
+ let wantsSummary = false;
360
+ let customInstructions: string | undefined;
361
+
358
362
  const branchSummariesEnabled = this.ctx.settingsManager.getBranchSummaryEnabled();
359
- const wantsSummary = branchSummariesEnabled
360
- ? await this.ctx.showHookConfirm(
361
- "Summarize branch?",
362
- "Create a summary of the branch you're leaving?",
363
- )
364
- : false;
363
+
364
+ while (branchSummariesEnabled) {
365
+ const summaryChoice = await this.ctx.showHookSelector("Summarize branch?", [
366
+ "No summary",
367
+ "Summarize",
368
+ "Summarize with custom prompt",
369
+ ]);
370
+
371
+ if (summaryChoice === undefined) {
372
+ // User pressed escape - re-show tree selector
373
+ this.showTreeSelector();
374
+ return;
375
+ }
376
+
377
+ wantsSummary = summaryChoice !== "No summary";
378
+
379
+ if (summaryChoice === "Summarize with custom prompt") {
380
+ customInstructions = await this.ctx.showHookEditor("Custom summarization instructions");
381
+ if (customInstructions === undefined) {
382
+ // User cancelled - loop back to summary selector
383
+ continue;
384
+ }
385
+ }
386
+
387
+ // User made a complete choice
388
+ break;
389
+ }
365
390
 
366
391
  // Set up escape handler and loader if summarizing
367
392
  let summaryLoader: Loader | undefined;
@@ -384,7 +409,10 @@ export class SelectorController {
384
409
  }
385
410
 
386
411
  try {
387
- const result = await this.ctx.session.navigateTree(entryId, { summarize: wantsSummary });
412
+ const result = await this.ctx.session.navigateTree(entryId, {
413
+ summarize: wantsSummary,
414
+ customInstructions,
415
+ });
388
416
 
389
417
  if (result.aborted) {
390
418
  // Summarization aborted - re-show tree selector
@@ -109,6 +109,8 @@ export class InteractiveMode implements InteractiveModeContext {
109
109
  public lastEscapeTime = 0;
110
110
  public lastVoiceInterruptAt = 0;
111
111
  public voiceAutoModeEnabled = false;
112
+ public shutdownRequested = false;
113
+ private isShuttingDown = false;
112
114
  public voiceProgressTimer: ReturnType<typeof setTimeout> | undefined = undefined;
113
115
  public voiceProgressSpoken = false;
114
116
  public voiceProgressLastLength = 0;
@@ -118,6 +120,7 @@ export class InteractiveMode implements InteractiveModeContext {
118
120
  public lastStatusSpacer: Spacer | undefined = undefined;
119
121
  public lastStatusText: Text | undefined = undefined;
120
122
  public fileSlashCommands: Set<string> = new Set();
123
+ public skillCommands: Map<string, string> = new Map();
121
124
 
122
125
  private pendingSlashCommands: SlashCommand[] = [];
123
126
  private cleanupUnsubscribe?: () => void;
@@ -231,8 +234,18 @@ export class InteractiveMode implements InteractiveModeContext {
231
234
  description: `${loaded.command.description} (${loaded.source})`,
232
235
  }));
233
236
 
237
+ // Build skill commands from session.skills (if enabled)
238
+ const skillCommandList: SlashCommand[] = [];
239
+ if (this.settingsManager.getEnableSkillCommands?.()) {
240
+ for (const skill of this.session.skills) {
241
+ const commandName = `skill:${skill.name}`;
242
+ this.skillCommands.set(commandName, skill.filePath);
243
+ skillCommandList.push({ name: commandName, description: skill.description });
244
+ }
245
+ }
246
+
234
247
  // Store pending commands for init() where file commands are loaded async
235
- this.pendingSlashCommands = [...slashCommands, ...hookCommands, ...customCommands];
248
+ this.pendingSlashCommands = [...slashCommands, ...hookCommands, ...customCommands, ...skillCommandList];
236
249
 
237
250
  this.uiHelpers = new UiHelpers(this);
238
251
  this.voiceManager = new VoiceManager(this);
@@ -486,6 +499,9 @@ export class InteractiveMode implements InteractiveModeContext {
486
499
  }
487
500
 
488
501
  async shutdown(): Promise<void> {
502
+ if (this.isShuttingDown) return;
503
+ this.isShuttingDown = true;
504
+
489
505
  this.voiceAutoModeEnabled = false;
490
506
  await this.voiceSupervisor.stop();
491
507
 
@@ -499,6 +515,11 @@ export class InteractiveMode implements InteractiveModeContext {
499
515
  process.exit(0);
500
516
  }
501
517
 
518
+ async checkShutdownRequested(): Promise<void> {
519
+ if (!this.shutdownRequested) return;
520
+ await this.shutdown();
521
+ }
522
+
502
523
  // Extension UI integration
503
524
  setToolUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void {
504
525
  this.toolUiContextSetter(uiContext, hasUI);
@@ -74,6 +74,7 @@ export interface InteractiveModeContext {
74
74
  lastEscapeTime: number;
75
75
  lastVoiceInterruptAt: number;
76
76
  voiceAutoModeEnabled: boolean;
77
+ shutdownRequested: boolean;
77
78
  voiceProgressTimer: ReturnType<typeof setTimeout> | undefined;
78
79
  voiceProgressSpoken: boolean;
79
80
  voiceProgressLastLength: number;
@@ -83,11 +84,13 @@ export interface InteractiveModeContext {
83
84
  lastStatusSpacer: Spacer | undefined;
84
85
  lastStatusText: Text | undefined;
85
86
  fileSlashCommands: Set<string>;
87
+ skillCommands: Map<string, string>;
86
88
  todoItems: TodoItem[];
87
89
 
88
90
  // Lifecycle
89
91
  init(): Promise<void>;
90
92
  shutdown(): Promise<void>;
93
+ checkShutdownRequested(): Promise<void>;
91
94
 
92
95
  // Extension UI integration
93
96
  setToolUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void;
@@ -189,6 +189,14 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
189
189
  // Component factories are not supported in RPC mode - would need TUI access
190
190
  },
191
191
 
192
+ setFooter(_factory: unknown): void {
193
+ // Custom footer not supported in RPC mode - requires TUI access
194
+ },
195
+
196
+ setHeader(_factory: unknown): void {
197
+ // Custom header not supported in RPC mode - requires TUI access
198
+ },
199
+
192
200
  setTitle(title: string): void {
193
201
  // Fire and forget - host can implement terminal title control
194
202
  output({
@@ -257,9 +265,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
257
265
  return { success: false, error: "Theme switching not supported in RPC mode" };
258
266
  },
259
267
 
260
- setFooter() {},
261
- setHeader() {},
262
- setEditorComponent() {},
268
+ setEditorComponent(): void {
269
+ // Custom editor components not supported in RPC mode
270
+ },
263
271
  });
264
272
 
265
273
  // Set up extensions with RPC-based UI context