@oh-my-pi/pi-coding-agent 1.340.0 → 2.0.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.
Files changed (153) hide show
  1. package/CHANGELOG.md +115 -1
  2. package/README.md +1 -1
  3. package/examples/custom-tools/subagent/index.ts +1 -1
  4. package/package.json +5 -3
  5. package/src/cli/args.ts +13 -6
  6. package/src/cli/file-processor.ts +3 -3
  7. package/src/cli/list-models.ts +2 -2
  8. package/src/cli/plugin-cli.ts +1 -1
  9. package/src/cli/session-picker.ts +2 -2
  10. package/src/cli.ts +1 -1
  11. package/src/config.ts +3 -3
  12. package/src/core/agent-session.ts +189 -29
  13. package/src/core/bash-executor.ts +50 -10
  14. package/src/core/compaction/branch-summarization.ts +5 -5
  15. package/src/core/compaction/compaction.ts +3 -3
  16. package/src/core/compaction/index.ts +3 -3
  17. package/src/core/custom-commands/bundled/review/index.ts +156 -0
  18. package/src/core/custom-commands/index.ts +15 -0
  19. package/src/core/custom-commands/loader.ts +232 -0
  20. package/src/core/custom-commands/types.ts +112 -0
  21. package/src/core/custom-tools/index.ts +3 -3
  22. package/src/core/custom-tools/loader.ts +10 -8
  23. package/src/core/custom-tools/types.ts +11 -6
  24. package/src/core/custom-tools/wrapper.ts +2 -1
  25. package/src/core/exec.ts +22 -12
  26. package/src/core/export-html/index.ts +5 -5
  27. package/src/core/file-mentions.ts +54 -0
  28. package/src/core/hooks/index.ts +5 -5
  29. package/src/core/hooks/loader.ts +21 -16
  30. package/src/core/hooks/runner.ts +6 -6
  31. package/src/core/hooks/tool-wrapper.ts +2 -2
  32. package/src/core/hooks/types.ts +12 -15
  33. package/src/core/index.ts +6 -6
  34. package/src/core/logger.ts +112 -0
  35. package/src/core/mcp/client.ts +3 -3
  36. package/src/core/mcp/config.ts +1 -1
  37. package/src/core/mcp/index.ts +12 -12
  38. package/src/core/mcp/loader.ts +2 -2
  39. package/src/core/mcp/manager.ts +6 -6
  40. package/src/core/mcp/tool-bridge.ts +3 -3
  41. package/src/core/mcp/transports/http.ts +1 -1
  42. package/src/core/mcp/transports/index.ts +2 -2
  43. package/src/core/mcp/transports/stdio.ts +1 -1
  44. package/src/core/messages.ts +22 -0
  45. package/src/core/model-registry.ts +2 -2
  46. package/src/core/model-resolver.ts +103 -2
  47. package/src/core/plugins/doctor.ts +1 -1
  48. package/src/core/plugins/index.ts +6 -6
  49. package/src/core/plugins/installer.ts +4 -4
  50. package/src/core/plugins/loader.ts +4 -9
  51. package/src/core/plugins/manager.ts +5 -5
  52. package/src/core/plugins/paths.ts +3 -3
  53. package/src/core/sdk.ts +127 -52
  54. package/src/core/session-manager.ts +123 -20
  55. package/src/core/settings-manager.ts +106 -22
  56. package/src/core/skills.ts +5 -5
  57. package/src/core/slash-commands.ts +60 -45
  58. package/src/core/system-prompt.ts +6 -6
  59. package/src/core/title-generator.ts +94 -0
  60. package/src/core/tools/bash.ts +33 -157
  61. package/src/core/tools/context.ts +2 -2
  62. package/src/core/tools/edit-diff.ts +5 -5
  63. package/src/core/tools/edit.ts +60 -9
  64. package/src/core/tools/exa/company.ts +3 -3
  65. package/src/core/tools/exa/index.ts +16 -17
  66. package/src/core/tools/exa/linkedin.ts +3 -3
  67. package/src/core/tools/exa/mcp-client.ts +9 -9
  68. package/src/core/tools/exa/render.ts +5 -5
  69. package/src/core/tools/exa/researcher.ts +3 -3
  70. package/src/core/tools/exa/search.ts +6 -5
  71. package/src/core/tools/exa/types.ts +5 -6
  72. package/src/core/tools/exa/websets.ts +3 -3
  73. package/src/core/tools/find.ts +3 -3
  74. package/src/core/tools/grep.ts +6 -5
  75. package/src/core/tools/index.ts +114 -40
  76. package/src/core/tools/ls.ts +4 -4
  77. package/src/core/tools/lsp/client.ts +204 -108
  78. package/src/core/tools/lsp/config.ts +709 -35
  79. package/src/core/tools/lsp/edits.ts +2 -2
  80. package/src/core/tools/lsp/index.ts +432 -30
  81. package/src/core/tools/lsp/render.ts +2 -2
  82. package/src/core/tools/lsp/rust-analyzer.ts +3 -3
  83. package/src/core/tools/lsp/types.ts +5 -0
  84. package/src/core/tools/lsp/utils.ts +1 -1
  85. package/src/core/tools/notebook.ts +1 -1
  86. package/src/core/tools/output.ts +175 -0
  87. package/src/core/tools/read.ts +7 -7
  88. package/src/core/tools/renderers.ts +92 -13
  89. package/src/core/tools/review.ts +268 -0
  90. package/src/core/tools/task/agents.ts +1 -1
  91. package/src/core/tools/task/bundled-agents/explore.md +1 -1
  92. package/src/core/tools/task/bundled-agents/reviewer.md +53 -38
  93. package/src/core/tools/task/discovery.ts +2 -2
  94. package/src/core/tools/task/executor.ts +145 -28
  95. package/src/core/tools/task/index.ts +78 -30
  96. package/src/core/tools/task/model-resolver.ts +72 -13
  97. package/src/core/tools/task/parallel.ts +1 -1
  98. package/src/core/tools/task/render.ts +219 -30
  99. package/src/core/tools/task/subprocess-tool-registry.ts +89 -0
  100. package/src/core/tools/task/types.ts +36 -2
  101. package/src/core/tools/web-fetch.ts +5 -3
  102. package/src/core/tools/web-search/auth.ts +1 -1
  103. package/src/core/tools/web-search/index.ts +17 -15
  104. package/src/core/tools/web-search/providers/anthropic.ts +2 -2
  105. package/src/core/tools/web-search/providers/exa.ts +3 -5
  106. package/src/core/tools/web-search/providers/perplexity.ts +1 -1
  107. package/src/core/tools/web-search/render.ts +3 -3
  108. package/src/core/tools/write.ts +70 -7
  109. package/src/index.ts +33 -17
  110. package/src/main.ts +60 -34
  111. package/src/migrations.ts +3 -3
  112. package/src/modes/index.ts +5 -5
  113. package/src/modes/interactive/components/armin.ts +1 -1
  114. package/src/modes/interactive/components/assistant-message.ts +1 -1
  115. package/src/modes/interactive/components/bash-execution.ts +4 -4
  116. package/src/modes/interactive/components/bordered-loader.ts +2 -2
  117. package/src/modes/interactive/components/branch-summary-message.ts +2 -2
  118. package/src/modes/interactive/components/compaction-summary-message.ts +2 -2
  119. package/src/modes/interactive/components/diff.ts +1 -1
  120. package/src/modes/interactive/components/dynamic-border.ts +1 -1
  121. package/src/modes/interactive/components/footer.ts +5 -5
  122. package/src/modes/interactive/components/hook-editor.ts +2 -2
  123. package/src/modes/interactive/components/hook-input.ts +2 -2
  124. package/src/modes/interactive/components/hook-message.ts +3 -3
  125. package/src/modes/interactive/components/hook-selector.ts +2 -2
  126. package/src/modes/interactive/components/model-selector.ts +341 -41
  127. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  128. package/src/modes/interactive/components/plugin-settings.ts +4 -4
  129. package/src/modes/interactive/components/queue-mode-selector.ts +2 -2
  130. package/src/modes/interactive/components/session-selector.ts +24 -11
  131. package/src/modes/interactive/components/settings-defs.ts +51 -3
  132. package/src/modes/interactive/components/settings-selector.ts +13 -16
  133. package/src/modes/interactive/components/show-images-selector.ts +2 -2
  134. package/src/modes/interactive/components/theme-selector.ts +2 -2
  135. package/src/modes/interactive/components/thinking-selector.ts +2 -2
  136. package/src/modes/interactive/components/tool-execution.ts +44 -8
  137. package/src/modes/interactive/components/tree-selector.ts +5 -5
  138. package/src/modes/interactive/components/user-message-selector.ts +2 -2
  139. package/src/modes/interactive/components/user-message.ts +1 -1
  140. package/src/modes/interactive/components/welcome.ts +42 -5
  141. package/src/modes/interactive/interactive-mode.ts +169 -48
  142. package/src/modes/interactive/theme/theme.ts +8 -7
  143. package/src/modes/print-mode.ts +4 -3
  144. package/src/modes/rpc/rpc-client.ts +4 -4
  145. package/src/modes/rpc/rpc-mode.ts +21 -11
  146. package/src/modes/rpc/rpc-types.ts +3 -3
  147. package/src/utils/changelog.ts +2 -2
  148. package/src/utils/clipboard.ts +1 -1
  149. package/src/utils/shell-snapshot.ts +218 -0
  150. package/src/utils/shell.ts +93 -13
  151. package/src/utils/tools-manager.ts +1 -1
  152. package/examples/custom-tools/subagent/agents/reviewer.md +0 -35
  153. package/src/core/tools/exa/logger.ts +0 -56
@@ -1,5 +1,3 @@
1
- import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
- import type { ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
3
1
  import {
4
2
  appendFileSync,
5
3
  closeSync,
@@ -11,16 +9,18 @@ import {
11
9
  readSync,
12
10
  statSync,
13
11
  writeFileSync,
14
- } from "fs";
15
- import { join, resolve } from "path";
16
- import { getAgentDir as getDefaultAgentDir } from "../config.js";
12
+ } from "node:fs";
13
+ import { join, resolve } from "node:path";
14
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
15
+ import type { ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
16
+ import { getAgentDir as getDefaultAgentDir } from "../config";
17
17
  import {
18
18
  type BashExecutionMessage,
19
19
  createBranchSummaryMessage,
20
20
  createCompactionSummaryMessage,
21
21
  createHookMessage,
22
22
  type HookMessage,
23
- } from "./messages.js";
23
+ } from "./messages";
24
24
 
25
25
  export const CURRENT_SESSION_VERSION = 2;
26
26
 
@@ -28,6 +28,7 @@ export interface SessionHeader {
28
28
  type: "session";
29
29
  version?: number; // v1 sessions don't have this
30
30
  id: string;
31
+ title?: string; // Auto-generated title from first message
31
32
  timestamp: string;
32
33
  cwd: string;
33
34
  parentSession?: string;
@@ -56,8 +57,10 @@ export interface ThinkingLevelChangeEntry extends SessionEntryBase {
56
57
 
57
58
  export interface ModelChangeEntry extends SessionEntryBase {
58
59
  type: "model_change";
59
- provider: string;
60
- modelId: string;
60
+ /** Model in "provider/modelId" format */
61
+ model: string;
62
+ /** Role: "default", "smol", "slow", etc. Undefined treated as "default" */
63
+ role?: string;
61
64
  }
62
65
 
63
66
  export interface CompactionEntry<T = unknown> extends SessionEntryBase {
@@ -149,12 +152,14 @@ export interface SessionTreeNode {
149
152
  export interface SessionContext {
150
153
  messages: AgentMessage[];
151
154
  thinkingLevel: string;
152
- model: { provider: string; modelId: string } | null;
155
+ /** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
156
+ models: Record<string, string>;
153
157
  }
154
158
 
155
159
  export interface SessionInfo {
156
160
  path: string;
157
161
  id: string;
162
+ title?: string;
158
163
  created: Date;
159
164
  modified: Date;
160
165
  messageCount: number;
@@ -290,7 +295,7 @@ export function buildSessionContext(
290
295
  let leaf: SessionEntry | undefined;
291
296
  if (leafId === null) {
292
297
  // Explicitly null - return no messages (navigated to before first entry)
293
- return { messages: [], thinkingLevel: "off", model: null };
298
+ return { messages: [], thinkingLevel: "off", models: {} };
294
299
  }
295
300
  if (leafId) {
296
301
  leaf = byId.get(leafId);
@@ -301,7 +306,7 @@ export function buildSessionContext(
301
306
  }
302
307
 
303
308
  if (!leaf) {
304
- return { messages: [], thinkingLevel: "off", model: null };
309
+ return { messages: [], thinkingLevel: "off", models: {} };
305
310
  }
306
311
 
307
312
  // Walk from leaf to root, collecting path
@@ -314,16 +319,21 @@ export function buildSessionContext(
314
319
 
315
320
  // Extract settings and find compaction
316
321
  let thinkingLevel = "off";
317
- let model: { provider: string; modelId: string } | null = null;
322
+ const models: Record<string, string> = {};
318
323
  let compaction: CompactionEntry | null = null;
319
324
 
320
325
  for (const entry of path) {
321
326
  if (entry.type === "thinking_level_change") {
322
327
  thinkingLevel = entry.thinkingLevel;
323
328
  } else if (entry.type === "model_change") {
324
- model = { provider: entry.provider, modelId: entry.modelId };
329
+ // New format: { model: "provider/id", role?: string }
330
+ if (entry.model) {
331
+ const role = entry.role ?? "default";
332
+ models[role] = entry.model;
333
+ }
325
334
  } else if (entry.type === "message" && entry.message.role === "assistant") {
326
- model = { provider: entry.message.provider, modelId: entry.message.model };
335
+ // Infer default model from assistant messages
336
+ models.default = `${entry.message.provider}/${entry.message.model}`;
327
337
  } else if (entry.type === "compaction") {
328
338
  compaction = entry;
329
339
  }
@@ -379,7 +389,7 @@ export function buildSessionContext(
379
389
  }
380
390
  }
381
391
 
382
- return { messages, thinkingLevel, model };
392
+ return { messages, thinkingLevel, models };
383
393
  }
384
394
 
385
395
  /**
@@ -454,6 +464,67 @@ export function findMostRecentSession(sessionDir: string): string | null {
454
464
  }
455
465
  }
456
466
 
467
+ /** Recent session info for display */
468
+ export interface RecentSessionInfo {
469
+ name: string;
470
+ path: string;
471
+ timeAgo: string;
472
+ }
473
+
474
+ /** Format a time difference as a human-readable string */
475
+ function formatTimeAgo(date: Date): string {
476
+ const now = Date.now();
477
+ const diffMs = now - date.getTime();
478
+ const diffMins = Math.floor(diffMs / 60000);
479
+ const diffHours = Math.floor(diffMs / 3600000);
480
+ const diffDays = Math.floor(diffMs / 86400000);
481
+
482
+ if (diffMins < 1) return "just now";
483
+ if (diffMins < 60) return `${diffMins}m ago`;
484
+ if (diffHours < 24) return `${diffHours}h ago`;
485
+ if (diffDays < 7) return `${diffDays}d ago`;
486
+ return date.toLocaleDateString();
487
+ }
488
+
489
+ /** Get recent sessions for display in welcome screen */
490
+ export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionInfo[] {
491
+ try {
492
+ const files = readdirSync(sessionDir)
493
+ .filter((f) => f.endsWith(".jsonl"))
494
+ .map((f) => join(sessionDir, f))
495
+ .filter(isValidSessionFile)
496
+ .map((path) => {
497
+ const stat = statSync(path);
498
+ // Try to get session title or id from first line
499
+ let name = path.split("/").pop()?.replace(".jsonl", "") ?? "Unknown";
500
+ try {
501
+ const content = readFileSync(path, "utf-8");
502
+ const firstLine = content.split("\n")[0];
503
+ if (firstLine) {
504
+ const header = JSON.parse(firstLine) as SessionHeader;
505
+ if (header.type === "session") {
506
+ // Prefer title over id
507
+ name = header.title ?? header.id ?? name;
508
+ }
509
+ }
510
+ } catch {
511
+ // Use filename as fallback
512
+ }
513
+ return { path, name, mtime: stat.mtime };
514
+ })
515
+ .sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
516
+ .slice(0, limit);
517
+
518
+ return files.map((f) => ({
519
+ name: f.name.length > 40 ? `${f.name.slice(0, 37)}...` : f.name,
520
+ path: f.path,
521
+ timeAgo: formatTimeAgo(f.mtime),
522
+ }));
523
+ } catch {
524
+ return [];
525
+ }
526
+ }
527
+
457
528
  /**
458
529
  * Manages conversation sessions as append-only trees stored in JSONL files.
459
530
  *
@@ -467,6 +538,7 @@ export function findMostRecentSession(sessionDir: string): string | null {
467
538
  */
468
539
  export class SessionManager {
469
540
  private sessionId: string = "";
541
+ private sessionTitle: string | undefined;
470
542
  private sessionFile: string | undefined;
471
543
  private sessionDir: string;
472
544
  private cwd: string;
@@ -499,6 +571,7 @@ export class SessionManager {
499
571
  this.fileEntries = loadEntriesFromFile(this.sessionFile);
500
572
  const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
501
573
  this.sessionId = header?.id ?? crypto.randomUUID();
574
+ this.sessionTitle = header?.title;
502
575
 
503
576
  if (migrateToCurrentVersion(this.fileEntries)) {
504
577
  this._rewriteFile();
@@ -579,6 +652,31 @@ export class SessionManager {
579
652
  return this.sessionFile;
580
653
  }
581
654
 
655
+ getSessionTitle(): string | undefined {
656
+ return this.sessionTitle;
657
+ }
658
+
659
+ setSessionTitle(title: string): void {
660
+ this.sessionTitle = title;
661
+ // Update the session file header with the title
662
+ if (this.persist && this.sessionFile && existsSync(this.sessionFile)) {
663
+ try {
664
+ const content = readFileSync(this.sessionFile, "utf-8");
665
+ const lines = content.split("\n");
666
+ if (lines.length > 0) {
667
+ const header = JSON.parse(lines[0]) as SessionHeader;
668
+ if (header.type === "session") {
669
+ header.title = title;
670
+ lines[0] = JSON.stringify(header);
671
+ writeFileSync(this.sessionFile, lines.join("\n"));
672
+ }
673
+ }
674
+ } catch {
675
+ // Ignore errors updating title
676
+ }
677
+ }
678
+ }
679
+
582
680
  _persist(entry: SessionEntry): void {
583
681
  if (!this.persist || !this.sessionFile) return;
584
682
 
@@ -633,15 +731,19 @@ export class SessionManager {
633
731
  return entry.id;
634
732
  }
635
733
 
636
- /** Append a model change as child of current leaf, then advance leaf. Returns entry id. */
637
- appendModelChange(provider: string, modelId: string): string {
734
+ /**
735
+ * Append a model change as child of current leaf, then advance leaf. Returns entry id.
736
+ * @param model Model in "provider/modelId" format
737
+ * @param role Optional role (default: "default")
738
+ */
739
+ appendModelChange(model: string, role?: string): string {
638
740
  const entry: ModelChangeEntry = {
639
741
  type: "model_change",
640
742
  id: generateId(this.byId),
641
743
  parentId: this.leafId,
642
744
  timestamp: new Date().toISOString(),
643
- provider,
644
- modelId,
745
+ model,
746
+ role,
645
747
  };
646
748
  this._appendEntry(entry);
647
749
  return entry.id;
@@ -1061,7 +1163,7 @@ export class SessionManager {
1061
1163
  if (lines.length === 0) continue;
1062
1164
 
1063
1165
  // Check first line for valid session header
1064
- let header: { type: string; id: string; timestamp: string } | null = null;
1166
+ let header: { type: string; id: string; title?: string; timestamp: string } | null = null;
1065
1167
  try {
1066
1168
  const first = JSON.parse(lines[0]);
1067
1169
  if (first.type === "session" && first.id) {
@@ -1107,6 +1209,7 @@ export class SessionManager {
1107
1209
  sessions.push({
1108
1210
  path: file,
1109
1211
  id: header.id,
1212
+ title: header.title,
1110
1213
  created: new Date(header.timestamp),
1111
1214
  modified: stats.mtime,
1112
1215
  messageCount,
@@ -1,6 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
- import { dirname, join } from "path";
3
- import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { CONFIG_DIR_NAME, getAgentDir } from "../config";
4
4
 
5
5
  export interface CompactionSettings {
6
6
  enabled?: boolean; // default: true
@@ -30,6 +30,11 @@ export interface SkillsSettings {
30
30
  includeSkills?: string[]; // default: [] (empty = include all; glob patterns to filter)
31
31
  }
32
32
 
33
+ export interface CommandsSettings {
34
+ enableClaudeUser?: boolean; // default: true (load from ~/.claude/commands/)
35
+ enableClaudeProject?: boolean; // default: true (load from .claude/commands/)
36
+ }
37
+
33
38
  export interface TerminalSettings {
34
39
  showImages?: boolean; // default: true (only relevant if terminal supports images)
35
40
  }
@@ -51,12 +56,23 @@ export interface MCPSettings {
51
56
  enableProjectConfig?: boolean; // default: true (load .mcp.json from project root)
52
57
  }
53
58
 
59
+ export interface LspSettings {
60
+ formatOnWrite?: boolean; // default: true (format files using LSP after write tool writes code files)
61
+ diagnosticsOnWrite?: boolean; // default: true (return LSP diagnostics after write tool writes code files)
62
+ diagnosticsOnEdit?: boolean; // default: false (return LSP diagnostics after edit tool edits code files)
63
+ }
64
+
65
+ export interface EditSettings {
66
+ fuzzyMatch?: boolean; // default: true (accept high-confidence fuzzy matches for whitespace/indentation)
67
+ }
68
+
54
69
  export interface Settings {
55
70
  lastChangelogVersion?: string;
56
- defaultProvider?: string;
57
- defaultModel?: string;
71
+ /** Model roles map: { default: "provider/modelId", small: "provider/modelId", ... } */
72
+ modelRoles?: Record<string, string>;
58
73
  defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
59
74
  queueMode?: "all" | "one-at-a-time";
75
+ interruptMode?: "immediate" | "wait";
60
76
  theme?: string;
61
77
  compaction?: CompactionSettings;
62
78
  branchSummary?: BranchSummarySettings;
@@ -67,11 +83,14 @@ export interface Settings {
67
83
  hooks?: string[]; // Array of hook file paths
68
84
  customTools?: string[]; // Array of custom tool file paths
69
85
  skills?: SkillsSettings;
86
+ commands?: CommandsSettings;
70
87
  terminal?: TerminalSettings;
71
88
  enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
72
89
  exa?: ExaSettings;
73
90
  bashInterceptor?: BashInterceptorSettings;
74
91
  mcp?: MCPSettings;
92
+ lsp?: LspSettings;
93
+ edit?: EditSettings;
75
94
  }
76
95
 
77
96
  /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
@@ -200,28 +219,29 @@ export class SettingsManager {
200
219
  this.save();
201
220
  }
202
221
 
203
- getDefaultProvider(): string | undefined {
204
- return this.settings.defaultProvider;
222
+ /**
223
+ * Get model for a role. Returns "provider/modelId" string or undefined.
224
+ */
225
+ getModelRole(role: string): string | undefined {
226
+ return this.settings.modelRoles?.[role];
205
227
  }
206
228
 
207
- getDefaultModel(): string | undefined {
208
- return this.settings.defaultModel;
209
- }
210
-
211
- setDefaultProvider(provider: string): void {
212
- this.globalSettings.defaultProvider = provider;
229
+ /**
230
+ * Set model for a role. Model should be "provider/modelId" format.
231
+ */
232
+ setModelRole(role: string, model: string): void {
233
+ if (!this.globalSettings.modelRoles) {
234
+ this.globalSettings.modelRoles = {};
235
+ }
236
+ this.globalSettings.modelRoles[role] = model;
213
237
  this.save();
214
238
  }
215
239
 
216
- setDefaultModel(modelId: string): void {
217
- this.globalSettings.defaultModel = modelId;
218
- this.save();
219
- }
220
-
221
- setDefaultModelAndProvider(provider: string, modelId: string): void {
222
- this.globalSettings.defaultProvider = provider;
223
- this.globalSettings.defaultModel = modelId;
224
- this.save();
240
+ /**
241
+ * Get all model roles.
242
+ */
243
+ getModelRoles(): Record<string, string> {
244
+ return { ...this.settings.modelRoles };
225
245
  }
226
246
 
227
247
  getQueueMode(): "all" | "one-at-a-time" {
@@ -233,6 +253,15 @@ export class SettingsManager {
233
253
  this.save();
234
254
  }
235
255
 
256
+ getInterruptMode(): "immediate" | "wait" {
257
+ return this.settings.interruptMode || "immediate";
258
+ }
259
+
260
+ setInterruptMode(mode: "immediate" | "wait"): void {
261
+ this.globalSettings.interruptMode = mode;
262
+ this.save();
263
+ }
264
+
236
265
  getTheme(): string | undefined {
237
266
  return this.settings.theme;
238
267
  }
@@ -376,6 +405,13 @@ export class SettingsManager {
376
405
  };
377
406
  }
378
407
 
408
+ getCommandsSettings(): Required<CommandsSettings> {
409
+ return {
410
+ enableClaudeUser: this.settings.commands?.enableClaudeUser ?? true,
411
+ enableClaudeProject: this.settings.commands?.enableClaudeProject ?? true,
412
+ };
413
+ }
414
+
379
415
  getShowImages(): boolean {
380
416
  return this.settings.terminal?.showImages ?? true;
381
417
  }
@@ -474,4 +510,52 @@ export class SettingsManager {
474
510
  this.globalSettings.mcp.enableProjectConfig = enabled;
475
511
  this.save();
476
512
  }
513
+
514
+ getLspFormatOnWrite(): boolean {
515
+ return this.settings.lsp?.formatOnWrite ?? true;
516
+ }
517
+
518
+ setLspFormatOnWrite(enabled: boolean): void {
519
+ if (!this.globalSettings.lsp) {
520
+ this.globalSettings.lsp = {};
521
+ }
522
+ this.globalSettings.lsp.formatOnWrite = enabled;
523
+ this.save();
524
+ }
525
+
526
+ getLspDiagnosticsOnWrite(): boolean {
527
+ return this.settings.lsp?.diagnosticsOnWrite ?? true;
528
+ }
529
+
530
+ setLspDiagnosticsOnWrite(enabled: boolean): void {
531
+ if (!this.globalSettings.lsp) {
532
+ this.globalSettings.lsp = {};
533
+ }
534
+ this.globalSettings.lsp.diagnosticsOnWrite = enabled;
535
+ this.save();
536
+ }
537
+
538
+ getLspDiagnosticsOnEdit(): boolean {
539
+ return this.settings.lsp?.diagnosticsOnEdit ?? false;
540
+ }
541
+
542
+ setLspDiagnosticsOnEdit(enabled: boolean): void {
543
+ if (!this.globalSettings.lsp) {
544
+ this.globalSettings.lsp = {};
545
+ }
546
+ this.globalSettings.lsp.diagnosticsOnEdit = enabled;
547
+ this.save();
548
+ }
549
+
550
+ getEditFuzzyMatch(): boolean {
551
+ return this.settings.edit?.fuzzyMatch ?? true;
552
+ }
553
+
554
+ setEditFuzzyMatch(enabled: boolean): void {
555
+ if (!this.globalSettings.edit) {
556
+ this.globalSettings.edit = {};
557
+ }
558
+ this.globalSettings.edit.fuzzyMatch = enabled;
559
+ this.save();
560
+ }
477
561
  }
@@ -1,9 +1,9 @@
1
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "fs";
1
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join, resolve } from "node:path";
2
4
  import { minimatch } from "minimatch";
3
- import { homedir } from "os";
4
- import { basename, dirname, join, resolve } from "path";
5
- import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
6
- import type { SkillsSettings } from "./settings-manager.js";
5
+ import { CONFIG_DIR_NAME, getAgentDir } from "../config";
6
+ import type { SkillsSettings } from "./settings-manager";
7
7
 
8
8
  /**
9
9
  * Standard frontmatter fields per Agent Skills spec.
@@ -1,6 +1,8 @@
1
- import { existsSync, readdirSync, readFileSync } from "fs";
2
- import { join, resolve } from "path";
3
- import { CONFIG_DIR_NAME, getCommandsDir } from "../config.js";
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { CONFIG_DIR_NAME, getCommandsDir } from "../config";
5
+ import { logger } from "./logger";
4
6
 
5
7
  /**
6
8
  * Represents a custom slash command loaded from a file
@@ -98,14 +100,12 @@ export function substituteArgs(content: string, args: string[]): string {
98
100
  return result;
99
101
  }
100
102
 
103
+ type CommandSource = "builtin" | "claude-user" | "claude-project" | "user" | "project";
104
+
101
105
  /**
102
106
  * Recursively scan a directory for .md files (and symlinks to .md files) and load them as slash commands
103
107
  */
104
- function loadCommandsFromDir(
105
- dir: string,
106
- source: "builtin" | "user" | "project",
107
- subdir: string = "",
108
- ): FileSlashCommand[] {
108
+ function loadCommandsFromDir(dir: string, source: CommandSource, subdir: string = ""): FileSlashCommand[] {
109
109
  const commands: FileSlashCommand[] = [];
110
110
 
111
111
  if (!existsSync(dir)) {
@@ -129,15 +129,18 @@ function loadCommandsFromDir(
129
129
 
130
130
  const name = entry.name.slice(0, -3); // Remove .md extension
131
131
 
132
- // Build source string
133
- let sourceStr: string;
134
- if (source === "builtin") {
135
- sourceStr = subdir ? `(builtin:${subdir})` : "(builtin)";
136
- } else if (source === "user") {
137
- sourceStr = subdir ? `(user:${subdir})` : "(user)";
138
- } else {
139
- sourceStr = subdir ? `(project:${subdir})` : "(project)";
140
- }
132
+ // Build source string based on source type
133
+ const sourceLabel =
134
+ source === "builtin"
135
+ ? "builtin"
136
+ : source === "claude-user"
137
+ ? "claude-user"
138
+ : source === "claude-project"
139
+ ? "claude-project"
140
+ : source === "user"
141
+ ? "user"
142
+ : "project";
143
+ const sourceStr = subdir ? `(${sourceLabel}:${subdir})` : `(${sourceLabel})`;
141
144
 
142
145
  // Get description from frontmatter or first non-empty line
143
146
  let description = frontmatter.description || "";
@@ -159,13 +162,13 @@ function loadCommandsFromDir(
159
162
  content,
160
163
  source: sourceStr,
161
164
  });
162
- } catch (_error) {
163
- // Silently skip files that can't be read
165
+ } catch (err) {
166
+ logger.debug("Failed to read slash command file", { error: String(err) });
164
167
  }
165
168
  }
166
169
  }
167
- } catch (_error) {
168
- // Silently skip directories that can't be read
170
+ } catch (err) {
171
+ logger.debug("Failed to read slash command directory", { error: String(err) });
169
172
  }
170
173
 
171
174
  return commands;
@@ -176,54 +179,66 @@ export interface LoadSlashCommandsOptions {
176
179
  cwd?: string;
177
180
  /** Agent config directory for global commands. Default: from getCommandsDir() */
178
181
  agentDir?: string;
182
+ /** Enable loading from ~/.claude/commands/. Default: true */
183
+ enableClaudeUser?: boolean;
184
+ /** Enable loading from .claude/commands/. Default: true */
185
+ enableClaudeProject?: boolean;
179
186
  }
180
187
 
181
188
  /**
182
189
  * Load all custom slash commands from:
183
190
  * 1. Builtin: package commands/
184
- * 2. Global: agentDir/commands/
185
- * 3. Project: cwd/{CONFIG_DIR_NAME}/commands/
191
+ * 2. Claude user: ~/.claude/commands/
192
+ * 3. Claude project: .claude/commands/
193
+ * 4. Pi user: agentDir/commands/
194
+ * 5. Pi project: cwd/{CONFIG_DIR_NAME}/commands/
195
+ *
196
+ * First occurrence wins (earlier sources have priority).
186
197
  */
187
198
  export function loadSlashCommands(options: LoadSlashCommandsOptions = {}): FileSlashCommand[] {
188
199
  const resolvedCwd = options.cwd ?? process.cwd();
189
200
  const resolvedAgentDir = options.agentDir ?? getCommandsDir();
201
+ const enableClaudeUser = options.enableClaudeUser ?? true;
202
+ const enableClaudeProject = options.enableClaudeProject ?? true;
190
203
 
191
204
  const commands: FileSlashCommand[] = [];
192
205
  const seenNames = new Set<string>();
193
206
 
194
- // 1. Builtin commands (from package)
195
- const builtinDir = join(import.meta.dir, "../commands");
196
- if (existsSync(builtinDir)) {
197
- const builtinCommands = loadCommandsFromDir(builtinDir, "builtin");
198
- for (const cmd of builtinCommands) {
207
+ const addCommands = (newCommands: FileSlashCommand[]) => {
208
+ for (const cmd of newCommands) {
199
209
  if (!seenNames.has(cmd.name)) {
200
210
  commands.push(cmd);
201
211
  seenNames.add(cmd.name);
202
212
  }
203
213
  }
214
+ };
215
+
216
+ // 1. Builtin commands (from package)
217
+ const builtinDir = join(import.meta.dir, "../commands");
218
+ if (existsSync(builtinDir)) {
219
+ addCommands(loadCommandsFromDir(builtinDir, "builtin"));
204
220
  }
205
221
 
206
- // 2. Load global commands from agentDir/commands/
207
- // Note: if agentDir is provided, it should be the agent dir, not the commands dir
208
- const globalCommandsDir = options.agentDir ? join(options.agentDir, "commands") : resolvedAgentDir;
209
- const globalCommands = loadCommandsFromDir(globalCommandsDir, "user");
210
- for (const cmd of globalCommands) {
211
- if (!seenNames.has(cmd.name)) {
212
- commands.push(cmd);
213
- seenNames.add(cmd.name);
214
- }
222
+ // 2. Claude user commands (~/.claude/commands/)
223
+ if (enableClaudeUser) {
224
+ const claudeUserDir = join(homedir(), ".claude", "commands");
225
+ addCommands(loadCommandsFromDir(claudeUserDir, "claude-user"));
215
226
  }
216
227
 
217
- // 3. Load project commands from cwd/{CONFIG_DIR_NAME}/commands/
218
- const projectCommandsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "commands");
219
- const projectCommands = loadCommandsFromDir(projectCommandsDir, "project");
220
- for (const cmd of projectCommands) {
221
- if (!seenNames.has(cmd.name)) {
222
- commands.push(cmd);
223
- seenNames.add(cmd.name);
224
- }
228
+ // 3. Claude project commands (.claude/commands/)
229
+ if (enableClaudeProject) {
230
+ const claudeProjectDir = resolve(resolvedCwd, ".claude", "commands");
231
+ addCommands(loadCommandsFromDir(claudeProjectDir, "claude-project"));
225
232
  }
226
233
 
234
+ // 4. Pi user commands (agentDir/commands/)
235
+ const globalCommandsDir = options.agentDir ? join(options.agentDir, "commands") : resolvedAgentDir;
236
+ addCommands(loadCommandsFromDir(globalCommandsDir, "user"));
237
+
238
+ // 5. Pi project commands (cwd/{CONFIG_DIR_NAME}/commands/)
239
+ const projectCommandsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "commands");
240
+ addCommands(loadCommandsFromDir(projectCommandsDir, "project"));
241
+
227
242
  return commands;
228
243
  }
229
244
 
@@ -2,13 +2,13 @@
2
2
  * System prompt construction and project context loading
3
3
  */
4
4
 
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join, resolve } from "node:path";
5
7
  import chalk from "chalk";
6
- import { existsSync, readFileSync } from "fs";
7
- import { join, resolve } from "path";
8
- import { getAgentDir, getDocsPath, getExamplesPath, getReadmePath } from "../config.js";
9
- import type { SkillsSettings } from "./settings-manager.js";
10
- import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills.js";
11
- import type { ToolName } from "./tools/index.js";
8
+ import { getAgentDir, getDocsPath, getExamplesPath, getReadmePath } from "../config";
9
+ import type { SkillsSettings } from "./settings-manager";
10
+ import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills";
11
+ import type { ToolName } from "./tools/index";
12
12
 
13
13
  /**
14
14
  * Execute a git command synchronously and return stdout or null on failure.