@oh-my-pi/pi-coding-agent 3.37.1 → 4.0.1

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 (70) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +44 -3
  3. package/docs/extensions.md +29 -4
  4. package/docs/sdk.md +3 -3
  5. package/package.json +5 -5
  6. package/src/cli/args.ts +8 -0
  7. package/src/config.ts +5 -15
  8. package/src/core/agent-session.ts +217 -51
  9. package/src/core/auth-storage.ts +456 -47
  10. package/src/core/bash-executor.ts +79 -14
  11. package/src/core/custom-commands/types.ts +1 -1
  12. package/src/core/custom-tools/types.ts +1 -1
  13. package/src/core/export-html/index.ts +33 -1
  14. package/src/core/export-html/template.css +99 -0
  15. package/src/core/export-html/template.generated.ts +1 -1
  16. package/src/core/export-html/template.js +133 -8
  17. package/src/core/extensions/index.ts +22 -4
  18. package/src/core/extensions/loader.ts +152 -214
  19. package/src/core/extensions/runner.ts +139 -79
  20. package/src/core/extensions/types.ts +143 -19
  21. package/src/core/extensions/wrapper.ts +5 -8
  22. package/src/core/hooks/types.ts +1 -1
  23. package/src/core/index.ts +2 -1
  24. package/src/core/keybindings.ts +4 -1
  25. package/src/core/model-registry.ts +4 -4
  26. package/src/core/model-resolver.ts +35 -26
  27. package/src/core/sdk.ts +96 -76
  28. package/src/core/settings-manager.ts +45 -14
  29. package/src/core/system-prompt.ts +5 -15
  30. package/src/core/tools/bash.ts +115 -54
  31. package/src/core/tools/find.ts +86 -7
  32. package/src/core/tools/grep.ts +27 -6
  33. package/src/core/tools/index.ts +15 -6
  34. package/src/core/tools/ls.ts +49 -18
  35. package/src/core/tools/render-utils.ts +2 -1
  36. package/src/core/tools/task/worker.ts +35 -12
  37. package/src/core/tools/web-search/auth.ts +37 -32
  38. package/src/core/tools/web-search/providers/anthropic.ts +35 -22
  39. package/src/index.ts +101 -9
  40. package/src/main.ts +60 -20
  41. package/src/migrations.ts +47 -2
  42. package/src/modes/index.ts +2 -2
  43. package/src/modes/interactive/components/assistant-message.ts +25 -7
  44. package/src/modes/interactive/components/bash-execution.ts +5 -0
  45. package/src/modes/interactive/components/branch-summary-message.ts +5 -0
  46. package/src/modes/interactive/components/compaction-summary-message.ts +5 -0
  47. package/src/modes/interactive/components/countdown-timer.ts +38 -0
  48. package/src/modes/interactive/components/custom-editor.ts +8 -0
  49. package/src/modes/interactive/components/custom-message.ts +5 -0
  50. package/src/modes/interactive/components/footer.ts +2 -5
  51. package/src/modes/interactive/components/hook-input.ts +29 -20
  52. package/src/modes/interactive/components/hook-selector.ts +52 -38
  53. package/src/modes/interactive/components/index.ts +39 -0
  54. package/src/modes/interactive/components/login-dialog.ts +160 -0
  55. package/src/modes/interactive/components/model-selector.ts +10 -2
  56. package/src/modes/interactive/components/session-selector.ts +5 -1
  57. package/src/modes/interactive/components/settings-defs.ts +9 -0
  58. package/src/modes/interactive/components/status-line/segments.ts +3 -3
  59. package/src/modes/interactive/components/tool-execution.ts +9 -16
  60. package/src/modes/interactive/components/tree-selector.ts +1 -6
  61. package/src/modes/interactive/interactive-mode.ts +466 -215
  62. package/src/modes/interactive/theme/theme.ts +50 -2
  63. package/src/modes/print-mode.ts +78 -31
  64. package/src/modes/rpc/rpc-mode.ts +186 -78
  65. package/src/modes/rpc/rpc-types.ts +10 -3
  66. package/src/prompts/system-prompt.md +36 -28
  67. package/src/utils/clipboard.ts +90 -50
  68. package/src/utils/image-convert.ts +1 -1
  69. package/src/utils/image-resize.ts +1 -1
  70. package/src/utils/tools-manager.ts +2 -2
package/src/core/sdk.ts CHANGED
@@ -27,8 +27,8 @@
27
27
  */
28
28
 
29
29
  import { join } from "node:path";
30
- import { Agent, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
31
- import type { Model } from "@oh-my-pi/pi-ai";
30
+ import { Agent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
31
+ import type { Message, Model } from "@oh-my-pi/pi-ai";
32
32
  import type { Component } from "@oh-my-pi/pi-tui";
33
33
  import chalk from "chalk";
34
34
  // Import discovery to register all providers on startup
@@ -51,9 +51,10 @@ import {
51
51
  type ExtensionContext,
52
52
  type ExtensionFactory,
53
53
  ExtensionRunner,
54
+ type ExtensionUIContext,
54
55
  type LoadExtensionsResult,
55
- type LoadedExtension,
56
56
  loadExtensionFromFactory,
57
+ loadExtensions,
57
58
  type ToolDefinition,
58
59
  wrapRegisteredTools,
59
60
  wrapToolWithExtensions,
@@ -66,7 +67,7 @@ import { formatModelString, parseModelString } from "./model-resolver";
66
67
  import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./prompt-templates";
67
68
  import { SessionManager } from "./session-manager";
68
69
  import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager";
69
- import { loadSkills as loadSkillsInternal, type Skill } from "./skills";
70
+ import { loadSkills as loadSkillsInternal, type Skill, type SkillWarning } from "./skills";
70
71
  import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./slash-commands";
71
72
  import { closeAllConnections } from "./ssh/connection-manager";
72
73
  import { unmountAll } from "./ssh/sshfs-mount";
@@ -129,11 +130,13 @@ export interface CreateAgentSessionOptions {
129
130
  extensions?: ExtensionFactory[];
130
131
  /** Additional extension paths to load (merged with discovery). */
131
132
  additionalExtensionPaths?: string[];
133
+ /** Disable extension discovery (explicit paths still load). */
134
+ disableExtensionDiscovery?: boolean;
132
135
  /**
133
136
  * Pre-loaded extensions (skips file discovery).
134
137
  * @internal Used by CLI when extensions are loaded early to parse custom flags.
135
138
  */
136
- preloadedExtensions?: LoadedExtension[];
139
+ preloadedExtensions?: LoadExtensionsResult;
137
140
 
138
141
  /** Shared event bus for tool/extension communication. Default: creates new bus. */
139
142
  eventBus?: EventBus;
@@ -172,8 +175,10 @@ export interface CreateAgentSessionOptions {
172
175
  export interface CreateAgentSessionResult {
173
176
  /** The created session */
174
177
  session: AgentSession;
175
- /** Extensions result (for UI context setup in interactive mode) */
178
+ /** Extensions result (loaded extensions + runtime) */
176
179
  extensionsResult: LoadExtensionsResult;
180
+ /** Update tool UI context (interactive mode) */
181
+ setToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void;
177
182
  /** MCP manager for server lifecycle management (undefined if MCP disabled) */
178
183
  mcpManager?: MCPManager;
179
184
  /** Warning if session was restored with a different model than saved */
@@ -274,12 +279,15 @@ export async function discoverExtensions(cwd?: string): Promise<LoadExtensionsRe
274
279
  /**
275
280
  * Discover skills from cwd and agentDir.
276
281
  */
277
- export function discoverSkills(cwd?: string, _agentDir?: string, settings?: SkillsSettings): Skill[] {
278
- const { skills } = loadSkillsInternal({
282
+ export function discoverSkills(
283
+ cwd?: string,
284
+ _agentDir?: string,
285
+ settings?: SkillsSettings,
286
+ ): { skills: Skill[]; warnings: SkillWarning[] } {
287
+ return loadSkillsInternal({
279
288
  ...settings,
280
289
  cwd: cwd ?? process.cwd(),
281
290
  });
282
- return skills;
283
291
  }
284
292
 
285
293
  /**
@@ -380,6 +388,7 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
380
388
  extensions: manager.getExtensionPaths(),
381
389
  skills: manager.getSkillsSettings(),
382
390
  terminal: { showImages: manager.getShowImages() },
391
+ images: { autoResize: manager.getImageAutoResize(), blockImages: manager.getBlockImages() },
383
392
  };
384
393
  }
385
394
 
@@ -614,7 +623,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
614
623
  thinkingLevel = "off";
615
624
  }
616
625
 
617
- const skills = options.skills ?? discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
626
+ let skills: Skill[];
627
+ let skillWarnings: SkillWarning[];
628
+ if (options.skills !== undefined) {
629
+ skills = options.skills;
630
+ skillWarnings = [];
631
+ } else {
632
+ const discovered = discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
633
+ skills = discovered.skills;
634
+ skillWarnings = discovered.warnings;
635
+ }
618
636
  time("discoverSkills");
619
637
 
620
638
  // Discover rules
@@ -723,12 +741,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
723
741
 
724
742
  // Load extensions (discovers from standard locations + configured paths)
725
743
  let extensionsResult: LoadExtensionsResult;
726
- if (options.preloadedExtensions !== undefined && options.preloadedExtensions.length > 0) {
727
- extensionsResult = {
728
- extensions: options.preloadedExtensions,
729
- errors: [],
730
- setUIContext: () => {},
731
- };
744
+ if (options.disableExtensionDiscovery) {
745
+ const configuredPaths = options.additionalExtensionPaths ?? [];
746
+ extensionsResult = await loadExtensions(configuredPaths, cwd, eventBus);
747
+ time("loadExtensions");
748
+ for (const { path, error } of extensionsResult.errors) {
749
+ logger.error("Failed to load extension", { path, error });
750
+ }
751
+ } else if (options.preloadedExtensions) {
752
+ extensionsResult = options.preloadedExtensions;
732
753
  } else {
733
754
  // Merge CLI extension paths with settings extension paths
734
755
  const configuredPaths = [...(options.additionalExtensionPaths ?? []), ...settingsManager.getExtensionPaths()];
@@ -746,36 +767,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
746
767
 
747
768
  // Load inline extensions from factories
748
769
  if (inlineExtensions.length > 0) {
749
- const uiHolder: { ui: any; hasUI: boolean } = {
750
- ui: {
751
- select: async () => undefined,
752
- confirm: async () => false,
753
- input: async () => undefined,
754
- notify: () => {},
755
- setStatus: () => {},
756
- setWidget: () => {},
757
- setTitle: () => {},
758
- custom: async () => undefined as never,
759
- setEditorText: () => {},
760
- getEditorText: () => "",
761
- editor: async () => undefined,
762
- get theme() {
763
- return {} as any;
764
- },
765
- },
766
- hasUI: false,
767
- };
768
770
  for (let i = 0; i < inlineExtensions.length; i++) {
769
771
  const factory = inlineExtensions[i];
770
- const loaded = loadExtensionFromFactory(factory, cwd, eventBus, uiHolder, `<inline-${i}>`);
772
+ const loaded = await loadExtensionFromFactory(
773
+ factory,
774
+ cwd,
775
+ eventBus,
776
+ extensionsResult.runtime,
777
+ `<inline-${i}>`,
778
+ );
771
779
  extensionsResult.extensions.push(loaded);
772
780
  }
773
- const originalSetUIContext = extensionsResult.setUIContext;
774
- extensionsResult.setUIContext = (uiContext, hasUI) => {
775
- originalSetUIContext(uiContext, hasUI);
776
- uiHolder.ui = uiContext;
777
- uiHolder.hasUI = hasUI;
778
- };
779
781
  }
780
782
 
781
783
  // Discover custom commands (TypeScript slash commands)
@@ -787,7 +789,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
787
789
 
788
790
  let extensionRunner: ExtensionRunner | undefined;
789
791
  if (extensionsResult.extensions.length > 0) {
790
- extensionRunner = new ExtensionRunner(extensionsResult.extensions, cwd, sessionManager, modelRegistry);
792
+ extensionRunner = new ExtensionRunner(
793
+ extensionsResult.extensions,
794
+ extensionsResult.runtime,
795
+ cwd,
796
+ sessionManager,
797
+ modelRegistry,
798
+ );
791
799
  }
792
800
 
793
801
  const getSessionContext = () => ({
@@ -810,35 +818,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
810
818
  return { definition, extensionPath: "<sdk>" };
811
819
  }) ?? []),
812
820
  ];
813
- const wrappedExtensionTools = wrapRegisteredTools(allCustomTools, () => ({
814
- ui: extensionRunner?.getUIContext() ?? {
815
- select: async () => undefined,
816
- confirm: async () => false,
817
- input: async () => undefined,
818
- notify: () => {},
819
- setStatus: () => {},
820
- setWidget: () => {},
821
- setTitle: () => {},
822
- custom: async () => undefined as never,
823
- setEditorText: () => {},
824
- getEditorText: () => "",
825
- editor: async () => undefined,
826
- get theme() {
827
- return {} as any;
828
- },
829
- },
830
- hasUI: extensionRunner?.getHasUI() ?? false,
831
- cwd,
832
- sessionManager,
833
- modelRegistry,
834
- model: agent.state.model,
835
- isIdle: () => !session.isStreaming,
836
- abort: () => {
837
- session.abort();
838
- },
839
- hasPendingMessages: () => session.queuedMessageCount > 0,
840
- hasQueuedMessages: () => session.queuedMessageCount > 0,
841
- }));
821
+ const wrappedExtensionTools = extensionRunner ? wrapRegisteredTools(allCustomTools, extensionRunner) : [];
842
822
 
843
823
  // All built-in tools are active (conditional tools like git/ask return null from factory if disabled)
844
824
  const toolRegistry = new Map<string, AgentTool>();
@@ -894,9 +874,44 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
894
874
  const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd);
895
875
  time("discoverSlashCommands");
896
876
 
897
- const baseSetUIContext = extensionsResult.setUIContext;
898
- extensionsResult.setUIContext = (uiContext, hasUI) => {
899
- baseSetUIContext(uiContext, hasUI);
877
+ // Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth)
878
+ const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => {
879
+ const converted = convertToLlm(messages);
880
+ // Check setting dynamically so mid-session changes take effect
881
+ if (!settingsManager.getBlockImages()) {
882
+ return converted;
883
+ }
884
+ // Filter out ImageContent from all messages, replacing with text placeholder
885
+ return converted.map((msg) => {
886
+ if (msg.role === "user" || msg.role === "toolResult") {
887
+ const content = msg.content;
888
+ if (Array.isArray(content)) {
889
+ const hasImages = content.some((c) => c.type === "image");
890
+ if (hasImages) {
891
+ const filteredContent = content
892
+ .map((c) =>
893
+ c.type === "image" ? { type: "text" as const, text: "Image reading is disabled." } : c,
894
+ )
895
+ .filter(
896
+ (c, i, arr) =>
897
+ // Dedupe consecutive "Image reading is disabled." texts
898
+ !(
899
+ c.type === "text" &&
900
+ c.text === "Image reading is disabled." &&
901
+ i > 0 &&
902
+ arr[i - 1].type === "text" &&
903
+ (arr[i - 1] as { type: "text"; text: string }).text === "Image reading is disabled."
904
+ ),
905
+ );
906
+ return { ...msg, content: filteredContent };
907
+ }
908
+ }
909
+ }
910
+ return msg;
911
+ });
912
+ };
913
+
914
+ const setToolUIContext = (uiContext: ExtensionUIContext, hasUI: boolean) => {
900
915
  toolContextStore.setUIContext(uiContext, hasUI);
901
916
  };
902
917
 
@@ -907,7 +922,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
907
922
  thinkingLevel,
908
923
  tools: Array.from(toolRegistry.values()),
909
924
  },
910
- convertToLlm,
925
+ convertToLlm: convertToLlmWithBlockImages,
926
+ sessionId: sessionManager.getSessionId(),
911
927
  transformContext: extensionRunner
912
928
  ? async (messages) => {
913
929
  return extensionRunner.emitContext(messages);
@@ -916,6 +932,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
916
932
  steeringMode: settingsManager.getSteeringMode(),
917
933
  followUpMode: settingsManager.getFollowUpMode(),
918
934
  interruptMode: settingsManager.getInterruptMode(),
935
+ thinkingBudgets: settingsManager.getThinkingBudgets(),
919
936
  getToolContext: toolContextStore.getContext,
920
937
  getApiKey: async () => {
921
938
  const currentModel = agent.state.model;
@@ -951,6 +968,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
951
968
  slashCommands,
952
969
  extensionRunner,
953
970
  customCommands: customCommandsResult.commands,
971
+ skills,
972
+ skillWarnings,
954
973
  skillsSettings: settingsManager.getSkillsSettings(),
955
974
  modelRegistry,
956
975
  toolRegistry,
@@ -980,6 +999,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
980
999
  return {
981
1000
  session,
982
1001
  extensionsResult,
1002
+ setToolUIContext,
983
1003
  mcpManager,
984
1004
  modelFallbackMessage,
985
1005
  lspServers,
@@ -45,6 +45,14 @@ export interface TerminalSettings {
45
45
 
46
46
  export interface ImageSettings {
47
47
  autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility)
48
+ blockImages?: boolean; // default: false - when true, prevents all images from being sent to LLM providers
49
+ }
50
+
51
+ export interface ThinkingBudgetsSettings {
52
+ minimal?: number;
53
+ low?: number;
54
+ medium?: number;
55
+ high?: number;
48
56
  }
49
57
 
50
58
  export type NotificationMethod = "bell" | "osc99" | "osc9" | "auto" | "off";
@@ -179,6 +187,7 @@ export interface Settings {
179
187
  shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
180
188
  collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
181
189
  doubleEscapeAction?: "branch" | "tree"; // Action for double-escape with empty editor (default: "tree")
190
+ thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels
182
191
  /** Environment variables to set automatically on startup */
183
192
  env?: Record<string, string>;
184
193
  extensions?: string[]; // Array of extension file paths
@@ -489,23 +498,29 @@ export class SettingsManager {
489
498
  }
490
499
 
491
500
  private save(): void {
492
- if (!this.persist || !this.settingsPath) return;
493
-
494
- try {
495
- const dir = dirname(this.settingsPath);
496
- if (!existsSync(dir)) {
497
- mkdirSync(dir, { recursive: true });
498
- }
501
+ if (this.persist && this.settingsPath) {
502
+ try {
503
+ const dir = dirname(this.settingsPath);
504
+ if (!existsSync(dir)) {
505
+ mkdirSync(dir, { recursive: true });
506
+ }
499
507
 
500
- // Save only global settings (project settings are read-only)
501
- writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8");
508
+ // Re-read current file to preserve any settings added externally while running
509
+ const currentFileSettings = SettingsManager.loadFromFile(this.settingsPath);
510
+ // Merge: file settings as base, globalSettings (in-memory changes) as overrides
511
+ const mergedSettings = deepMergeSettings(currentFileSettings, this.globalSettings);
512
+ this.globalSettings = mergedSettings;
502
513
 
503
- // Re-merge project settings into active settings (preserve overrides)
504
- const projectSettings = this.loadProjectSettings();
505
- this.rebuildSettings(projectSettings);
506
- } catch (error) {
507
- console.error(`Warning: Could not save settings file: ${error}`);
514
+ // Save merged settings (project settings are read-only)
515
+ writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8");
516
+ } catch (error) {
517
+ console.error(`Warning: Could not save settings file: ${error}`);
518
+ }
508
519
  }
520
+
521
+ // Always re-merge to update active settings (needed for both file and inMemory modes)
522
+ const projectSettings = this.loadProjectSettings();
523
+ this.rebuildSettings(projectSettings);
509
524
  }
510
525
 
511
526
  getLastChangelogVersion(): string | undefined {
@@ -668,6 +683,10 @@ export class SettingsManager {
668
683
  };
669
684
  }
670
685
 
686
+ getThinkingBudgets(): ThinkingBudgetsSettings | undefined {
687
+ return this.settings.thinkingBudgets;
688
+ }
689
+
671
690
  getHideThinkingBlock(): boolean {
672
691
  return this.settings.hideThinkingBlock ?? false;
673
692
  }
@@ -773,6 +792,18 @@ export class SettingsManager {
773
792
  this.save();
774
793
  }
775
794
 
795
+ getBlockImages(): boolean {
796
+ return this.settings.images?.blockImages ?? false;
797
+ }
798
+
799
+ setBlockImages(blocked: boolean): void {
800
+ if (!this.globalSettings.images) {
801
+ this.globalSettings.images = {};
802
+ }
803
+ this.globalSettings.images.blockImages = blocked;
804
+ this.save();
805
+ }
806
+
776
807
  getEnabledModels(): string[] | undefined {
777
808
  return this.settings.enabledModels;
778
809
  }
@@ -9,7 +9,6 @@ import chalk from "chalk";
9
9
  import { contextFileCapability } from "../capability/context-file";
10
10
  import type { Rule } from "../capability/rule";
11
11
  import { systemPromptCapability } from "../capability/system-prompt";
12
- import { getDocsPath, getExamplesPath, getReadmePath } from "../config";
13
12
  import { type ContextFile, loadSync, type SystemPrompt as SystemPromptFile } from "../discovery/index";
14
13
  import systemPromptTemplate from "../prompts/system-prompt.md" with { type: "text" };
15
14
  import type { SkillsSettings } from "./settings-manager";
@@ -772,7 +771,11 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
772
771
  const contextFiles = providedContextFiles ?? loadProjectContextFiles({ cwd: resolvedCwd });
773
772
 
774
773
  // Build tools list based on selected tools
775
- const toolsList = toolNames?.map((name) => `- ${name}: ${toolDescriptions[name as ToolName]}`).join("\n") ?? "";
774
+ const selectedToolNames = toolNames ?? (["read", "bash", "edit", "write"] as ToolName[]);
775
+ const toolsList =
776
+ selectedToolNames.length > 0
777
+ ? selectedToolNames.map((name) => `- ${name}: ${toolDescriptions[name as ToolName]}`).join("\n")
778
+ : "(none)";
776
779
 
777
780
  // Resolve skills: use provided or discover
778
781
  const skills =
@@ -804,11 +807,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
804
807
  return prompt;
805
808
  }
806
809
 
807
- // Get absolute paths to documentation and examples
808
- const readmePath = getReadmePath();
809
- const docsPath = getDocsPath();
810
- const examplesPath = getExamplesPath();
811
-
812
810
  // Generate anti-bash rules (returns null if not applicable)
813
811
  const antiBashSection = generateAntiBashRules(Array.from(tools?.keys() ?? []));
814
812
  const environmentInfo = formatEnvironmentInfo();
@@ -821,11 +819,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
821
819
  const hasWrite = tools?.has("write");
822
820
  const hasRead = tools?.has("read");
823
821
 
824
- // Read-only mode notice (no bash, edit, or write)
825
- if (!hasBash && !hasEdit && !hasWrite) {
826
- guidelinesList.push("You are in READ-ONLY mode - you cannot modify files or execute arbitrary commands");
827
- }
828
-
829
822
  // Bash without edit/write = read-only bash mode
830
823
  if (hasBash && !hasEdit && !hasWrite) {
831
824
  guidelinesList.push(
@@ -870,9 +863,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
870
863
  antiBashSection: antiBashBlock,
871
864
  guidelines,
872
865
  environmentInfo,
873
- readmePath,
874
- docsPath,
875
- examplesPath,
876
866
  });
877
867
 
878
868
  prompt = appendBlock(prompt, resolvedAppendPrompt);
@@ -3,12 +3,14 @@ import type { AgentTool, AgentToolContext } from "@oh-my-pi/pi-agent-core";
3
3
  import type { Component } from "@oh-my-pi/pi-tui";
4
4
  import { Text } from "@oh-my-pi/pi-tui";
5
5
  import { Type } from "@sinclair/typebox";
6
+ import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate";
6
7
  import type { Theme } from "../../modes/interactive/theme/theme";
7
8
  import bashDescription from "../../prompts/tools/bash.md" with { type: "text" };
8
- import { executeBash } from "../bash-executor";
9
+ import { type BashExecutorOptions, executeBash, executeBashWithOperations } from "../bash-executor";
9
10
  import type { RenderResultOptions } from "../custom-tools/types";
10
11
  import { checkBashInterception, checkSimpleLsInterception } from "./bash-interceptor";
11
12
  import type { ToolSession } from "./index";
13
+ import { resolveToCwd } from "./path-utils";
12
14
  import { createToolUIKit } from "./render-utils";
13
15
  import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate";
14
16
 
@@ -25,7 +27,28 @@ export interface BashToolDetails {
25
27
  fullOutputPath?: string;
26
28
  }
27
29
 
28
- export function createBashTool(session: ToolSession): AgentTool<typeof bashSchema> {
30
+ /**
31
+ * Pluggable operations for bash execution.
32
+ * Override to delegate command execution to remote systems.
33
+ */
34
+ export interface BashOperations {
35
+ exec: (
36
+ command: string,
37
+ cwd: string,
38
+ options: {
39
+ onData: (data: Buffer) => void;
40
+ signal?: AbortSignal;
41
+ timeout?: number;
42
+ },
43
+ ) => Promise<{ exitCode: number | null }>;
44
+ }
45
+
46
+ export interface BashToolOptions {
47
+ /** Custom operations for command execution. Default: local shell */
48
+ operations?: BashOperations;
49
+ }
50
+
51
+ export function createBashTool(session: ToolSession, options?: BashToolOptions): AgentTool<typeof bashSchema> {
29
52
  return {
30
53
  name: "bash",
31
54
  label: "Bash",
@@ -53,11 +76,22 @@ export function createBashTool(session: ToolSession): AgentTool<typeof bashSchem
53
76
  }
54
77
  }
55
78
 
79
+ const commandCwd = workdir ? resolveToCwd(workdir, session.cwd) : session.cwd;
80
+ let cwdStat: Awaited<ReturnType<Bun.BunFile["stat"]>>;
81
+ try {
82
+ cwdStat = await Bun.file(commandCwd).stat();
83
+ } catch {
84
+ throw new Error(`Working directory does not exist: ${commandCwd}`);
85
+ }
86
+ if (!cwdStat.isDirectory()) {
87
+ throw new Error(`Working directory is not a directory: ${commandCwd}`);
88
+ }
89
+
56
90
  // Track output for streaming updates
57
91
  let currentOutput = "";
58
92
 
59
- const result = await executeBash(command, {
60
- cwd: workdir ?? session.cwd,
93
+ const executorOptions: BashExecutorOptions = {
94
+ cwd: commandCwd,
61
95
  timeout: timeout ? timeout * 1000 : undefined, // Convert to milliseconds
62
96
  signal,
63
97
  onChunk: (chunk) => {
@@ -72,7 +106,12 @@ export function createBashTool(session: ToolSession): AgentTool<typeof bashSchem
72
106
  });
73
107
  }
74
108
  },
75
- });
109
+ };
110
+
111
+ // Use custom operations if provided, otherwise use default local executor
112
+ const result = options?.operations
113
+ ? await executeBashWithOperations(command, commandCwd, options.operations, executorOptions)
114
+ : await executeBash(command, executorOptions);
76
115
 
77
116
  // Handle errors
78
117
  if (result.cancelled) {
@@ -125,12 +164,12 @@ interface BashRenderArgs {
125
164
  }
126
165
 
127
166
  interface BashRenderContext {
128
- /** Visual lines for truncated output (pre-computed by tool-execution) */
129
- visualLines?: string[];
130
- /** Number of lines skipped */
131
- skippedCount?: number;
132
- /** Total visual lines */
133
- totalVisualLines?: number;
167
+ /** Raw output text */
168
+ output?: string;
169
+ /** Whether output is expanded */
170
+ expanded?: boolean;
171
+ /** Number of preview lines when collapsed */
172
+ previewLines?: number;
134
173
  }
135
174
 
136
175
  export const bashToolRenderer = {
@@ -171,51 +210,18 @@ export const bashToolRenderer = {
171
210
  uiTheme: Theme,
172
211
  ): Component {
173
212
  const ui = createToolUIKit(uiTheme);
174
- const { expanded, renderContext } = options;
213
+ const { renderContext } = options;
175
214
  const details = result.details;
176
- const lines: string[] = [];
177
-
178
- // Get output text
179
- const textContent = result.content?.find((c) => c.type === "text")?.text ?? "";
180
- const output = textContent.trim();
181
-
182
- if (output) {
183
- if (expanded) {
184
- // Show all lines when expanded
185
- const styledOutput = output
186
- .split("\n")
187
- .map((line) => uiTheme.fg("toolOutput", line))
188
- .join("\n");
189
- lines.push(styledOutput);
190
- } else if (renderContext?.visualLines) {
191
- // Use pre-computed visual lines from tool-execution
192
- const { visualLines, skippedCount = 0, totalVisualLines = visualLines.length } = renderContext;
193
- if (skippedCount > 0) {
194
- lines.push(
195
- uiTheme.fg(
196
- "dim",
197
- `${uiTheme.format.ellipsis} (${skippedCount} earlier lines, showing ${visualLines.length} of ${totalVisualLines}) (ctrl+o to expand)`,
198
- ),
199
- );
200
- }
201
- lines.push(...visualLines);
202
- } else {
203
- // Fallback: show first few lines
204
- const outputLines = output.split("\n");
205
- const maxLines = 5;
206
- const displayLines = outputLines.slice(0, maxLines);
207
- const remaining = outputLines.length - maxLines;
208
-
209
- lines.push(...displayLines.map((line) => uiTheme.fg("toolOutput", line)));
210
- if (remaining > 0) {
211
- lines.push(uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${remaining} more lines) (ctrl+o to expand)`));
212
- }
213
- }
214
- }
215
215
 
216
- // Truncation warnings
216
+ // Get output from context (preferred) or fall back to result content
217
+ const output = renderContext?.output ?? (result.content?.find((c) => c.type === "text")?.text ?? "").trim();
218
+ const expanded = renderContext?.expanded ?? options.expanded;
219
+ const previewLines = renderContext?.previewLines ?? 5;
220
+
221
+ // Build truncation warning lines (static, doesn't depend on width)
217
222
  const truncation = details?.truncation;
218
223
  const fullOutputPath = details?.fullOutputPath;
224
+ let warningLine: string | undefined;
219
225
  if (truncation?.truncated || fullOutputPath) {
220
226
  const warnings: string[] = [];
221
227
  if (fullOutputPath) {
@@ -230,9 +236,64 @@ export const bashToolRenderer = {
230
236
  );
231
237
  }
232
238
  }
233
- lines.push(uiTheme.fg("warning", ui.wrapBrackets(warnings.join(". "))));
239
+ warningLine = uiTheme.fg("warning", ui.wrapBrackets(warnings.join(". ")));
240
+ }
241
+
242
+ if (!output) {
243
+ // No output - just show warning if any
244
+ return new Text(warningLine ?? "", 0, 0);
245
+ }
246
+
247
+ if (expanded) {
248
+ // Show all lines when expanded
249
+ const styledOutput = output
250
+ .split("\n")
251
+ .map((line) => uiTheme.fg("toolOutput", line))
252
+ .join("\n");
253
+ const lines = warningLine ? [styledOutput, warningLine] : [styledOutput];
254
+ return new Text(lines.join("\n"), 0, 0);
234
255
  }
235
256
 
236
- return new Text(lines.join("\n"), 0, 0);
257
+ // Collapsed: use width-aware caching component
258
+ const styledOutput = output
259
+ .split("\n")
260
+ .map((line) => uiTheme.fg("toolOutput", line))
261
+ .join("\n");
262
+ const textContent = `\n${styledOutput}`;
263
+
264
+ let cachedWidth: number | undefined;
265
+ let cachedLines: string[] | undefined;
266
+ let cachedSkipped: number | undefined;
267
+
268
+ return {
269
+ render: (width: number): string[] => {
270
+ if (cachedLines === undefined || cachedWidth !== width) {
271
+ const result = truncateToVisualLines(textContent, previewLines, width);
272
+ cachedLines = result.visualLines;
273
+ cachedSkipped = result.skippedCount;
274
+ cachedWidth = width;
275
+ }
276
+ const outputLines: string[] = [];
277
+ if (cachedSkipped && cachedSkipped > 0) {
278
+ outputLines.push("");
279
+ outputLines.push(
280
+ uiTheme.fg(
281
+ "dim",
282
+ `${uiTheme.format.ellipsis} (${cachedSkipped} earlier lines, showing ${cachedLines.length} of ${cachedSkipped + cachedLines.length}) (ctrl+o to expand)`,
283
+ ),
284
+ );
285
+ }
286
+ outputLines.push(...cachedLines);
287
+ if (warningLine) {
288
+ outputLines.push(warningLine);
289
+ }
290
+ return outputLines;
291
+ },
292
+ invalidate: () => {
293
+ cachedWidth = undefined;
294
+ cachedLines = undefined;
295
+ cachedSkipped = undefined;
296
+ },
297
+ };
237
298
  },
238
299
  };