@oh-my-pi/pi-coding-agent 1.341.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 (151) hide show
  1. package/CHANGELOG.md +73 -0
  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 +5 -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 +157 -15
  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 +2 -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 +77 -35
  54. package/src/core/session-manager.ts +6 -6
  55. package/src/core/settings-manager.ts +16 -3
  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 +2 -2
  60. package/src/core/tools/bash.ts +32 -155
  61. package/src/core/tools/context.ts +2 -2
  62. package/src/core/tools/edit-diff.ts +3 -3
  63. package/src/core/tools/edit.ts +18 -5
  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 +3 -3
  75. package/src/core/tools/index.ts +48 -34
  76. package/src/core/tools/ls.ts +4 -4
  77. package/src/core/tools/lsp/client.ts +161 -90
  78. package/src/core/tools/lsp/config.ts +1 -1
  79. package/src/core/tools/lsp/edits.ts +2 -2
  80. package/src/core/tools/lsp/index.ts +15 -13
  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/utils.ts +1 -1
  84. package/src/core/tools/notebook.ts +1 -1
  85. package/src/core/tools/output.ts +175 -0
  86. package/src/core/tools/read.ts +7 -7
  87. package/src/core/tools/renderers.ts +92 -13
  88. package/src/core/tools/review.ts +268 -0
  89. package/src/core/tools/task/agents.ts +1 -1
  90. package/src/core/tools/task/bundled-agents/reviewer.md +52 -37
  91. package/src/core/tools/task/discovery.ts +2 -2
  92. package/src/core/tools/task/executor.ts +145 -28
  93. package/src/core/tools/task/index.ts +78 -30
  94. package/src/core/tools/task/model-resolver.ts +30 -20
  95. package/src/core/tools/task/parallel.ts +1 -1
  96. package/src/core/tools/task/render.ts +219 -30
  97. package/src/core/tools/task/subprocess-tool-registry.ts +89 -0
  98. package/src/core/tools/task/types.ts +36 -2
  99. package/src/core/tools/web-fetch.ts +5 -3
  100. package/src/core/tools/web-search/auth.ts +1 -1
  101. package/src/core/tools/web-search/index.ts +17 -15
  102. package/src/core/tools/web-search/providers/anthropic.ts +2 -2
  103. package/src/core/tools/web-search/providers/exa.ts +3 -5
  104. package/src/core/tools/web-search/providers/perplexity.ts +1 -1
  105. package/src/core/tools/web-search/render.ts +3 -3
  106. package/src/core/tools/write.ts +4 -4
  107. package/src/index.ts +29 -18
  108. package/src/main.ts +37 -32
  109. package/src/migrations.ts +3 -3
  110. package/src/modes/index.ts +5 -5
  111. package/src/modes/interactive/components/armin.ts +1 -1
  112. package/src/modes/interactive/components/assistant-message.ts +1 -1
  113. package/src/modes/interactive/components/bash-execution.ts +4 -4
  114. package/src/modes/interactive/components/bordered-loader.ts +2 -2
  115. package/src/modes/interactive/components/branch-summary-message.ts +2 -2
  116. package/src/modes/interactive/components/compaction-summary-message.ts +2 -2
  117. package/src/modes/interactive/components/diff.ts +1 -1
  118. package/src/modes/interactive/components/dynamic-border.ts +1 -1
  119. package/src/modes/interactive/components/footer.ts +5 -5
  120. package/src/modes/interactive/components/hook-editor.ts +2 -2
  121. package/src/modes/interactive/components/hook-input.ts +2 -2
  122. package/src/modes/interactive/components/hook-message.ts +3 -3
  123. package/src/modes/interactive/components/hook-selector.ts +2 -2
  124. package/src/modes/interactive/components/model-selector.ts +281 -59
  125. package/src/modes/interactive/components/oauth-selector.ts +3 -3
  126. package/src/modes/interactive/components/plugin-settings.ts +4 -4
  127. package/src/modes/interactive/components/queue-mode-selector.ts +2 -2
  128. package/src/modes/interactive/components/session-selector.ts +4 -4
  129. package/src/modes/interactive/components/settings-defs.ts +1 -1
  130. package/src/modes/interactive/components/settings-selector.ts +5 -5
  131. package/src/modes/interactive/components/show-images-selector.ts +2 -2
  132. package/src/modes/interactive/components/theme-selector.ts +2 -2
  133. package/src/modes/interactive/components/thinking-selector.ts +2 -2
  134. package/src/modes/interactive/components/tool-execution.ts +26 -8
  135. package/src/modes/interactive/components/tree-selector.ts +3 -3
  136. package/src/modes/interactive/components/user-message-selector.ts +2 -2
  137. package/src/modes/interactive/components/user-message.ts +1 -1
  138. package/src/modes/interactive/components/welcome.ts +2 -2
  139. package/src/modes/interactive/interactive-mode.ts +85 -41
  140. package/src/modes/interactive/theme/theme.ts +8 -7
  141. package/src/modes/print-mode.ts +4 -3
  142. package/src/modes/rpc/rpc-client.ts +4 -4
  143. package/src/modes/rpc/rpc-mode.ts +21 -11
  144. package/src/modes/rpc/rpc-types.ts +3 -3
  145. package/src/utils/changelog.ts +2 -2
  146. package/src/utils/clipboard.ts +1 -1
  147. package/src/utils/shell-snapshot.ts +218 -0
  148. package/src/utils/shell.ts +93 -13
  149. package/src/utils/tools-manager.ts +1 -1
  150. package/examples/custom-tools/subagent/agents/reviewer.md +0 -35
  151. package/src/core/tools/exa/logger.ts +0 -56
package/CHANGELOG.md CHANGED
@@ -2,6 +2,79 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.0.1337] - 2026-01-03
6
+ ### Added
7
+
8
+ - Added shell environment snapshot to preserve user aliases, functions, and shell options when executing bash commands
9
+ - Added support for `PI_BASH_NO_CI`, `PI_BASH_NO_LOGIN`, and `PI_SHELL_PREFIX` environment variables for shell customization
10
+ - Added zsh support alongside bash for shell detection and configuration
11
+
12
+ ### Changed
13
+
14
+ - Changed shell detection to prefer user's `$SHELL` when it's bash or zsh, with improved fallback path resolution
15
+ - Changed Edit tool to reject `.ipynb` files with guidance to use NotebookEdit tool instead
16
+
17
+ ## [1.500.0] - 2026-01-03
18
+ ### Added
19
+
20
+ - Added provider tabs to model selector with Tab/Arrow navigation for filtering models by provider
21
+ - Added context menu to model selector for choosing model role (Default, Smol, Slow) instead of keyboard shortcuts
22
+ - Added LSP diagnostics display in tool execution output showing errors and warnings after file edits
23
+ - Added centralized file logger with daily rotation to `~/.pi/logs/` for debugging production issues
24
+ - Added `logger` property to hook and custom tool APIs for error/warning/debug logging
25
+ - Added `output` tool to read full agent/task outputs by ID when truncated previews are insufficient
26
+ - Added `task` tool to reviewer agent, enabling parallel exploration of large codebases during reviews
27
+ - Added subprocess tool registry for extracting and rendering tool data from subprocess agents in real-time
28
+ - Added combined review result rendering showing verdict and findings in a tree structure
29
+ - Auto-read file mentions: Reference files with `@path/to/file.ext` syntax in prompts to automatically inject their contents, eliminating manual Read tool calls
30
+ - Added `hidden` property for custom tools to exclude them from default tool list unless explicitly requested
31
+ - Added `explicitTools` option to `createAgentSession` for enabling hidden tools by name
32
+ - Added example review tools (`report_finding`, `submit_review`) with structured findings accumulation and verdict rendering
33
+ - Added `/review` example command for interactive code review with branch comparison, uncommitted changes, and commit review modes
34
+ - Custom TypeScript slash commands: Create programmable commands at `~/.pi/agent/commands/[name]/index.ts` or `.pi/commands/[name]/index.ts`. Commands export a factory returning `{ name, description, execute(args, ctx) }`. Return a string to send as LLM prompt, or void for fire-and-forget actions. Full access to `HookCommandContext` for UI dialogs, session control, and shell execution.
35
+ - Claude command directories: Markdown slash commands now also load from `~/.claude/commands/` and `.claude/commands/` (parallel to existing `.pi/commands/` support)
36
+ - `commands.enableClaudeUser` and `commands.enableClaudeProject` settings to disable Claude command directory loading
37
+ - `/export --copy` option to copy entire session as formatted text to clipboard
38
+
39
+ ### Changed
40
+
41
+ - Changed model selector keyboard shortcuts from S/L keys to a context menu opened with Enter
42
+ - Changed model role indicators from symbols (✓ ⚡ 🧠) to labeled badges ([ DEFAULT ] [ SMOL ] [ SLOW ])
43
+ - Changed model list sorting to include secondary sort by model ID within each provider
44
+ - Changed silent error suppression to log warnings and debug info for tool errors, theme loading, and command loading failures
45
+ - Changed Task tool progress display to show agent index (e.g., `reviewer(0)`) for easier Output tool ID derivation
46
+ - Changed Task tool output to only include file paths when Output tool is unavailable, providing Read tool fallback
47
+ - Changed Task tool output references to use simpler ID format (e.g., `reviewer_0`) with line/char counts for Output tool integration
48
+ - Changed subagent recursion prevention from blanket blocking to same-agent blocking. Non-recursive agents can now spawn other agent types (e.g., reviewer can spawn explore agents) but cannot spawn themselves.
49
+ - Changed `/review` command from markdown to interactive TypeScript with mode selection menu (branch comparison, uncommitted changes, commit review, custom)
50
+ - Changed bundled commands to be overridable by user/project commands with same name
51
+ - Changed subprocess termination to wait for message_end event to capture accurate token counts
52
+ - Changed token counting in subprocess to accumulate across messages instead of overwriting
53
+ - Updated bundled `reviewer` agent to use structured review tools with priority-based findings (P0-P3) and formal verdict submission
54
+ - Task tool now streams artifacts in real-time: input written before spawn, session jsonl written by subprocess, output written at completion
55
+
56
+ ### Removed
57
+
58
+ - Removed separate Exa error logger in favor of centralized logging system
59
+ - Removed `findings_count` parameter from `submit_review` tool - findings are now counted automatically
60
+ - Removed artifacts location display from task tool output
61
+
62
+ ### Fixed
63
+
64
+ - Fixed race condition in event listener iteration by copying array before iteration to prevent mutation during callbacks
65
+ - Fixed potential memory leak from orphaned abort controllers by properly aborting existing controllers before replacement
66
+ - Fixed stream reader resource leak by adding proper `releaseLock()` calls in finally blocks
67
+ - Fixed hook API methods throwing clear errors when handlers are not initialized instead of silently failing
68
+ - Fixed LSP client race conditions with concurrent client creation and file operations using proper locking
69
+ - Fixed Task tool progress display showing stale data by cloning progress objects before passing to callbacks
70
+ - Fixed Task tool missing final progress events by waiting for readline to close before resolving
71
+ - Fixed RPC mode race condition with concurrent prompt commands by serializing execution
72
+ - Fixed pre-commit hook race condition causing `index.lock` errors when GitKraken/IDE git integrations detect file changes during formatting
73
+ - Fixed Task tool output artifacts (`out.md`) containing duplicated text from streaming updates
74
+ - Fixed Task tool progress display showing repeated nearly-identical lines during streaming
75
+ - Fixed Task tool subprocess model selection ignoring agent's configured model and falling back to settings default. The `--model` flag now accepts `provider/model` format directly.
76
+ - Fixed Task tool showing "done + succeeded" when aborted; now correctly displays "⊘ aborted" status
77
+
5
78
  ## [1.341.0] - 2026-01-03
6
79
  ### Added
7
80
 
package/README.md CHANGED
@@ -192,7 +192,7 @@ The agent reads, writes, and edits files, and executes commands via bash.
192
192
  | ------------------------- | --------------------------------------------------------------------------- |
193
193
  | `/settings` | Open settings menu (thinking, theme, queue mode, toggles) |
194
194
  | `/model` | Switch models mid-session (fuzzy search, arrow keys, Enter to select) |
195
- | `/export [file]` | Export session to self-contained HTML |
195
+ | `/export [file\|--copy]` | Export session to HTML file or copy to clipboard |
196
196
  | `/share` | Upload session as secret GitHub gist, get shareable URL (requires `gh` CLI) |
197
197
  | `/session` | Show session info: path, message counts, token usage, cost |
198
198
  | `/hotkeys` | Show all keyboard shortcuts |
@@ -18,7 +18,7 @@ import * as path from "node:path";
18
18
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
19
19
  import type { Message } from "@oh-my-pi/pi-ai";
20
20
  import type { CustomTool, CustomToolAPI, CustomToolFactory } from "@oh-my-pi/pi-coding-agent";
21
- import { type AgentConfig, type AgentScope, discoverAgents, formatAgentList } from "./agents.js";
21
+ import { type AgentConfig, type AgentScope, discoverAgents, formatAgentList } from "./agents";
22
22
 
23
23
  const MAX_PARALLEL_TASKS = 8;
24
24
  const MAX_CONCURRENCY = 4;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "1.341.0",
3
+ "version": "2.0.1337",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "piConfig": {
@@ -33,7 +33,7 @@
33
33
  "clean": "rm -rf dist",
34
34
  "build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js && npm run copy-assets",
35
35
  "build:binary": "npm run build && bun build --compile ./dist/cli.js --outfile dist/pi && npm run copy-binary-assets",
36
- "copy-assets": "mkdir -p dist/modes/interactive/theme && cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && mkdir -p dist/core/export-html && cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/",
36
+ "copy-assets": "mkdir -p dist/modes/interactive/theme && cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && mkdir -p dist/core/export-html && cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && mkdir -p dist/core/tools/task/bundled-agents && cp src/core/tools/task/bundled-agents/*.md dist/core/tools/task/bundled-agents/",
37
37
  "copy-binary-assets": "cp package.json dist/ && cp README.md dist/ && cp CHANGELOG.md dist/ && mkdir -p dist/theme && cp src/modes/interactive/theme/*.json dist/theme/ && mkdir -p dist/export-html && cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/export-html/ && cp -r docs dist/ && cp -r examples dist/",
38
38
  "test": "vitest --run",
39
39
  "prepublishOnly": "npm run clean && npm run build"
@@ -53,7 +53,9 @@
53
53
  "highlight.js": "^11.11.1",
54
54
  "marked": "^15.0.12",
55
55
  "minimatch": "^10.1.1",
56
- "strip-ansi": "^7.1.2"
56
+ "strip-ansi": "^7.1.2",
57
+ "winston": "^3.17.0",
58
+ "winston-daily-rotate-file": "^5.0.0"
57
59
  },
58
60
  "devDependencies": {
59
61
  "@types/diff": "^7.0.2",
package/src/cli/args.ts CHANGED
@@ -4,8 +4,8 @@
4
4
 
5
5
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
6
6
  import chalk from "chalk";
7
- import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR } from "../config.js";
8
- import { allTools, type ToolName } from "../core/tools/index.js";
7
+ import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR } from "../config";
8
+ import { allTools, type ToolName } from "../core/tools/index";
9
9
 
10
10
  export type Mode = "text" | "json" | "rpc";
11
11
 
@@ -152,8 +152,7 @@ ${chalk.bold("Usage:")}
152
152
  ${APP_NAME} [options] [@files...] [messages...]
153
153
 
154
154
  ${chalk.bold("Options:")}
155
- --provider <name> Provider name (default: google)
156
- --model <id> Model ID (default: gemini-2.5-flash)
155
+ --model <pattern> Model to use (fuzzy match: "opus", "gpt-5.2", or "p-openai/gpt-5.2")
157
156
  --smol <id> Smol/fast model for lightweight tasks (or PI_SMOL_MODEL env)
158
157
  --slow <id> Slow/reasoning model for thorough analysis (or PI_SLOW_MODEL env)
159
158
  --api-key <key> API key (defaults to env vars)
@@ -199,8 +198,8 @@ ${chalk.bold("Examples:")}
199
198
  # Continue previous session
200
199
  ${APP_NAME} --continue "What did we discuss?"
201
200
 
202
- # Use different model
203
- ${APP_NAME} --provider openai --model gpt-4o-mini "Help me refactor this code"
201
+ # Use different model (fuzzy matching)
202
+ ${APP_NAME} --model opus "Help me refactor this code"
204
203
 
205
204
  # Limit model cycling to specific models
206
205
  ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o
@@ -3,11 +3,11 @@
3
3
  */
4
4
 
5
5
  import { access, readFile, stat } from "node:fs/promises";
6
+ import { resolve } from "node:path";
6
7
  import type { ImageContent } from "@oh-my-pi/pi-ai";
7
8
  import chalk from "chalk";
8
- import { resolve } from "path";
9
- import { resolveReadPath } from "../core/tools/path-utils.js";
10
- import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.js";
9
+ import { resolveReadPath } from "../core/tools/path-utils";
10
+ import { detectSupportedImageMimeTypeFromFile } from "../utils/mime";
11
11
 
12
12
  export interface ProcessedFiles {
13
13
  text: string;
@@ -3,8 +3,8 @@
3
3
  */
4
4
 
5
5
  import type { Api, Model } from "@oh-my-pi/pi-ai";
6
- import type { ModelRegistry } from "../core/model-registry.js";
7
- import { fuzzyFilter } from "../utils/fuzzy.js";
6
+ import type { ModelRegistry } from "../core/model-registry";
7
+ import { fuzzyFilter } from "../utils/fuzzy";
8
8
 
9
9
  /**
10
10
  * Format a number as human-readable (e.g., 200000 -> "200K", 1000000 -> "1M")
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import chalk from "chalk";
8
- import { PluginManager, parseSettingValue, validateSetting } from "../core/plugins/index.js";
8
+ import { PluginManager, parseSettingValue, validateSetting } from "../core/plugins/index";
9
9
 
10
10
  // =============================================================================
11
11
  // Types
@@ -3,8 +3,8 @@
3
3
  */
4
4
 
5
5
  import { ProcessTerminal, TUI } from "@oh-my-pi/pi-tui";
6
- import type { SessionInfo } from "../core/session-manager.js";
7
- import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js";
6
+ import type { SessionInfo } from "../core/session-manager";
7
+ import { SessionSelectorComponent } from "../modes/interactive/components/session-selector";
8
8
 
9
9
  /** Show TUI session selector and return selected session path or null if cancelled */
10
10
  export async function selectSession(sessions: SessionInfo[]): Promise<string | null> {
package/src/cli.ts CHANGED
@@ -5,6 +5,6 @@
5
5
  *
6
6
  * Test with: npx tsx src/cli-new.ts [args...]
7
7
  */
8
- import { main } from "./main.js";
8
+ import { main } from "./main";
9
9
 
10
10
  main(process.argv.slice(2));
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { existsSync, readFileSync } from "fs";
2
- import { homedir } from "os";
3
- import { dirname, join, resolve } from "path";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
4
 
5
5
  // =============================================================================
6
6
  // Package Detection
@@ -16,8 +16,8 @@
16
16
  import type { Agent, AgentEvent, AgentMessage, AgentState, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
17
17
  import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
18
18
  import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai";
19
- import { getAuthPath } from "../config.js";
20
- import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js";
19
+ import { getAuthPath } from "../config";
20
+ import { type BashResult, executeBash as executeBashCommand } from "./bash-executor";
21
21
  import {
22
22
  type CompactionResult,
23
23
  calculateContextTokens,
@@ -26,9 +26,11 @@ import {
26
26
  generateBranchSummary,
27
27
  prepareCompaction,
28
28
  shouldCompact,
29
- } from "./compaction/index.js";
30
- import type { CustomToolContext, CustomToolSessionEvent, LoadedCustomTool } from "./custom-tools/index.js";
31
- import { exportSessionToHtml } from "./export-html/index.js";
29
+ } from "./compaction/index";
30
+ import type { LoadedCustomCommand } from "./custom-commands/index";
31
+ import type { CustomToolContext, CustomToolSessionEvent, LoadedCustomTool } from "./custom-tools/index";
32
+ import { exportSessionToHtml } from "./export-html/index";
33
+ import { extractFileMentions, generateFileMentionMessages } from "./file-mentions";
32
34
  import type {
33
35
  HookRunner,
34
36
  SessionBeforeBranchResult,
@@ -38,12 +40,13 @@ import type {
38
40
  TreePreparation,
39
41
  TurnEndEvent,
40
42
  TurnStartEvent,
41
- } from "./hooks/index.js";
42
- import type { BashExecutionMessage, HookMessage } from "./messages.js";
43
- import type { ModelRegistry } from "./model-registry.js";
44
- import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js";
45
- import type { SettingsManager, SkillsSettings } from "./settings-manager.js";
46
- import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js";
43
+ } from "./hooks/index";
44
+ import { logger } from "./logger";
45
+ import type { BashExecutionMessage, HookMessage } from "./messages";
46
+ import type { ModelRegistry } from "./model-registry";
47
+ import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager";
48
+ import type { SettingsManager, SkillsSettings } from "./settings-manager";
49
+ import { expandSlashCommand, type FileSlashCommand, parseCommandArgs } from "./slash-commands";
47
50
 
48
51
  /** Session-specific events that extend the core AgentEvent */
49
52
  export type AgentSessionEvent =
@@ -72,6 +75,8 @@ export interface AgentSessionConfig {
72
75
  hookRunner?: HookRunner;
73
76
  /** Custom tools for session lifecycle events */
74
77
  customTools?: LoadedCustomTool[];
78
+ /** Custom commands (TypeScript slash commands) */
79
+ customCommands?: LoadedCustomCommand[];
75
80
  skillsSettings?: Required<SkillsSettings>;
76
81
  /** Model registry for API key resolution and model discovery */
77
82
  modelRegistry: ModelRegistry;
@@ -166,6 +171,9 @@ export class AgentSession {
166
171
  // Custom tools for session lifecycle
167
172
  private _customTools: LoadedCustomTool[] = [];
168
173
 
174
+ // Custom commands (TypeScript slash commands)
175
+ private _customCommands: LoadedCustomCommand[] = [];
176
+
169
177
  private _skillsSettings: Required<SkillsSettings> | undefined;
170
178
 
171
179
  // Model registry for API key resolution
@@ -179,6 +187,7 @@ export class AgentSession {
179
187
  this._fileCommands = config.fileCommands ?? [];
180
188
  this._hookRunner = config.hookRunner;
181
189
  this._customTools = config.customTools ?? [];
190
+ this._customCommands = config.customCommands ?? [];
182
191
  this._skillsSettings = config.skillsSettings;
183
192
  this._modelRegistry = config.modelRegistry;
184
193
 
@@ -198,7 +207,9 @@ export class AgentSession {
198
207
 
199
208
  /** Emit an event to all listeners */
200
209
  private _emit(event: AgentSessionEvent): void {
201
- for (const l of this._eventListeners) {
210
+ // Copy array before iteration to avoid mutation during iteration
211
+ const listeners = [...this._eventListeners];
212
+ for (const l of listeners) {
202
213
  l(event);
203
214
  }
204
215
  }
@@ -449,6 +460,11 @@ export class AgentSession {
449
460
  return this._fileCommands;
450
461
  }
451
462
 
463
+ /** Custom commands (TypeScript slash commands) */
464
+ get customCommands(): ReadonlyArray<LoadedCustomCommand> {
465
+ return this._customCommands;
466
+ }
467
+
452
468
  // =========================================================================
453
469
  // Prompting
454
470
  // =========================================================================
@@ -473,6 +489,17 @@ export class AgentSession {
473
489
  // Hook command executed, no prompt to send
474
490
  return;
475
491
  }
492
+
493
+ // Try custom commands (TypeScript slash commands)
494
+ const customResult = await this._tryExecuteCustomCommand(text);
495
+ if (customResult !== null) {
496
+ if (customResult === "") {
497
+ // Command handled, nothing to send
498
+ return;
499
+ }
500
+ // Command returned a prompt - use it instead of the original text
501
+ text = customResult;
502
+ }
476
503
  }
477
504
 
478
505
  // Validate model
@@ -516,6 +543,13 @@ export class AgentSession {
516
543
  timestamp: Date.now(),
517
544
  });
518
545
 
546
+ // Auto-read @filepath mentions
547
+ const fileMentions = extractFileMentions(expandedText);
548
+ if (fileMentions.length > 0) {
549
+ const fileMentionMessages = await generateFileMentionMessages(fileMentions, this.sessionManager.getCwd());
550
+ messages.push(...fileMentionMessages);
551
+ }
552
+
519
553
  // Emit before_agent_start hook event
520
554
  if (this._hookRunner) {
521
555
  const result = await this._hookRunner.emitBeforeAgentStart(expandedText, options?.images);
@@ -566,6 +600,43 @@ export class AgentSession {
566
600
  }
567
601
  }
568
602
 
603
+ /**
604
+ * Try to execute a custom command. Returns the prompt string if found, null otherwise.
605
+ * If the command returns void, returns empty string to indicate it was handled.
606
+ */
607
+ private async _tryExecuteCustomCommand(text: string): Promise<string | null> {
608
+ if (this._customCommands.length === 0) return null;
609
+ if (!this._hookRunner) return null; // Need hook runner for command context
610
+
611
+ // Parse command name and args
612
+ const spaceIndex = text.indexOf(" ");
613
+ const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
614
+ const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
615
+
616
+ // Find matching command
617
+ const loaded = this._customCommands.find((c) => c.command.name === commandName);
618
+ if (!loaded) return null;
619
+
620
+ // Get command context from hook runner (includes session control methods)
621
+ const ctx = this._hookRunner.createCommandContext();
622
+
623
+ try {
624
+ const args = parseCommandArgs(argsString);
625
+ const result = await loaded.command.execute(args, ctx);
626
+ // If result is a string, it's a prompt to send to LLM
627
+ // If void/undefined, command handled everything
628
+ return result ?? "";
629
+ } catch (err) {
630
+ // Emit error via hook runner
631
+ this._hookRunner.emitError({
632
+ hookPath: `custom-command:${commandName}`,
633
+ event: "command",
634
+ error: err instanceof Error ? err.message : String(err),
635
+ });
636
+ return ""; // Command was handled (with error)
637
+ }
638
+ }
639
+
569
640
  /**
570
641
  * Queue a message to be sent after the current response completes.
571
642
  * Use when agent is currently streaming.
@@ -1058,6 +1129,10 @@ export class AgentSession {
1058
1129
  const settings = this.settingsManager.getCompactionSettings();
1059
1130
 
1060
1131
  this._emit({ type: "auto_compaction_start", reason });
1132
+ // Properly abort and null existing controller before replacing
1133
+ if (this._autoCompactionAbortController) {
1134
+ this._autoCompactionAbortController.abort();
1135
+ }
1061
1136
  this._autoCompactionAbortController = new AbortController();
1062
1137
 
1063
1138
  try {
@@ -1231,7 +1306,8 @@ export class AgentSession {
1231
1306
  this._retryAttempt++;
1232
1307
 
1233
1308
  // Create retry promise on first attempt so waitForRetry() can await it
1234
- if (this._retryAttempt === 1 && !this._retryPromise) {
1309
+ // Ensure only one promise exists (avoid orphaned promises from concurrent calls)
1310
+ if (!this._retryPromise) {
1235
1311
  this._retryPromise = new Promise((resolve) => {
1236
1312
  this._retryResolve = resolve;
1237
1313
  });
@@ -1267,6 +1343,10 @@ export class AgentSession {
1267
1343
  }
1268
1344
 
1269
1345
  // Wait with exponential backoff (abortable)
1346
+ // Properly abort and null existing controller before replacing
1347
+ if (this._retryAbortController) {
1348
+ this._retryAbortController.abort();
1349
+ }
1270
1350
  this._retryAbortController = new AbortController();
1271
1351
  try {
1272
1352
  await this._sleep(delayMs, this._retryAbortController.signal);
@@ -1858,6 +1938,68 @@ export class AgentSession {
1858
1938
  return text.trim() || undefined;
1859
1939
  }
1860
1940
 
1941
+ /**
1942
+ * Format the entire session as plain text for clipboard export.
1943
+ * Includes user messages, assistant text, thinking blocks, tool calls, and tool results.
1944
+ */
1945
+ formatSessionAsText(): string {
1946
+ const lines: string[] = [];
1947
+
1948
+ for (const msg of this.messages) {
1949
+ if (msg.role === "user") {
1950
+ lines.push("## User\n");
1951
+ if (typeof msg.content === "string") {
1952
+ lines.push(msg.content);
1953
+ } else {
1954
+ for (const c of msg.content) {
1955
+ if (c.type === "text") {
1956
+ lines.push(c.text);
1957
+ } else if (c.type === "image") {
1958
+ lines.push("[Image]");
1959
+ }
1960
+ }
1961
+ }
1962
+ lines.push("\n");
1963
+ } else if (msg.role === "assistant") {
1964
+ const assistantMsg = msg as AssistantMessage;
1965
+ lines.push("## Assistant\n");
1966
+
1967
+ for (const c of assistantMsg.content) {
1968
+ if (c.type === "text") {
1969
+ lines.push(c.text);
1970
+ } else if (c.type === "thinking") {
1971
+ lines.push("<thinking>");
1972
+ lines.push(c.thinking);
1973
+ lines.push("</thinking>\n");
1974
+ } else if (c.type === "toolCall") {
1975
+ lines.push(`### Tool: ${c.name}`);
1976
+ lines.push("```json");
1977
+ lines.push(JSON.stringify(c.arguments, null, 2));
1978
+ lines.push("```\n");
1979
+ }
1980
+ }
1981
+ lines.push("");
1982
+ } else if (msg.role === "toolResult") {
1983
+ lines.push(`### Tool Result: ${msg.toolName}`);
1984
+ if (msg.isError) {
1985
+ lines.push("(error)");
1986
+ }
1987
+ for (const c of msg.content) {
1988
+ if (c.type === "text") {
1989
+ lines.push("```");
1990
+ lines.push(c.text);
1991
+ lines.push("```");
1992
+ } else if (c.type === "image") {
1993
+ lines.push("[Image output]");
1994
+ }
1995
+ }
1996
+ lines.push("");
1997
+ }
1998
+ }
1999
+
2000
+ return lines.join("\n").trim();
2001
+ }
2002
+
1861
2003
  // =========================================================================
1862
2004
  // Hook System
1863
2005
  // =========================================================================
@@ -1909,8 +2051,8 @@ export class AgentSession {
1909
2051
  if (tool.onSession) {
1910
2052
  try {
1911
2053
  await tool.onSession(event, ctx);
1912
- } catch (_err) {
1913
- // Silently ignore tool errors during session events
2054
+ } catch (err) {
2055
+ logger.warn("Tool onSession error", { error: String(err) });
1914
2056
  }
1915
2057
  }
1916
2058
  }
@@ -11,14 +11,19 @@ import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import type { Subprocess } from "bun";
13
13
  import stripAnsi from "strip-ansi";
14
- import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell.js";
15
- import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
14
+ import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell";
15
+ import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot";
16
+ import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate";
16
17
 
17
18
  // ============================================================================
18
19
  // Types
19
20
  // ============================================================================
20
21
 
21
22
  export interface BashExecutorOptions {
23
+ /** Working directory for command execution */
24
+ cwd?: string;
25
+ /** Timeout in milliseconds */
26
+ timeout?: number;
22
27
  /** Callback for streaming output chunks (already sanitized) */
23
28
  onChunk?: (chunk: string) => void;
24
29
  /** AbortSignal for cancellation */
@@ -56,13 +61,24 @@ export interface BashResult {
56
61
  * @param options - Optional streaming callback and abort signal
57
62
  * @returns Promise resolving to execution result
58
63
  */
59
- export function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
64
+ export async function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
65
+ const { shell, args, env, prefix } = getShellConfig();
66
+
67
+ // Get or create shell snapshot (for aliases, functions, options)
68
+ const snapshotPath = await getOrCreateSnapshot(shell, env);
69
+ const snapshotPrefix = getSnapshotSourceCommand(snapshotPath);
70
+
71
+ // Build final command: snapshot + prefix + command
72
+ const prefixedCommand = prefix ? `${prefix} ${command}` : command;
73
+ const finalCommand = `${snapshotPrefix}${prefixedCommand}`;
74
+
60
75
  return new Promise((resolve, reject) => {
61
- const { shell, args } = getShellConfig();
62
- const child: Subprocess = Bun.spawn([shell, ...args, command], {
76
+ const child: Subprocess = Bun.spawn([shell, ...args, finalCommand], {
77
+ cwd: options?.cwd,
63
78
  stdin: "ignore",
64
79
  stdout: "pipe",
65
80
  stderr: "pipe",
81
+ env,
66
82
  });
67
83
 
68
84
  // Track sanitized output for truncation
@@ -74,16 +90,27 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
74
90
  let tempFilePath: string | undefined;
75
91
  let tempFileStream: WriteStream | undefined;
76
92
  let totalBytes = 0;
93
+ let timedOut = false;
77
94
 
78
- // Handle abort signal
95
+ // Handle abort signal and timeout
79
96
  const abortHandler = () => {
80
97
  killProcessTree(child.pid);
81
98
  };
82
99
 
100
+ // Set up timeout if specified
101
+ let timeoutHandle: Timer | undefined;
102
+ if (options?.timeout && options.timeout > 0) {
103
+ timeoutHandle = setTimeout(() => {
104
+ timedOut = true;
105
+ abortHandler();
106
+ }, options.timeout);
107
+ }
108
+
83
109
  if (options?.signal) {
84
110
  if (options.signal.aborted) {
85
111
  // Already aborted, don't even start
86
112
  child.kill();
113
+ if (timeoutHandle) clearTimeout(timeoutHandle);
87
114
  resolve({
88
115
  output: "",
89
116
  exitCode: undefined,
@@ -156,11 +183,11 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
156
183
 
157
184
  const exitCode = await child.exited;
158
185
 
159
- // Clean up abort listener
186
+ // Clean up
187
+ if (timeoutHandle) clearTimeout(timeoutHandle);
160
188
  if (options?.signal) {
161
189
  options.signal.removeEventListener("abort", abortHandler);
162
190
  }
163
-
164
191
  if (tempFileStream) {
165
192
  tempFileStream.end();
166
193
  }
@@ -169,6 +196,19 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
169
196
  const fullOutput = outputChunks.join("");
170
197
  const truncationResult = truncateTail(fullOutput);
171
198
 
199
+ // Handle timeout
200
+ if (timedOut) {
201
+ const timeoutSecs = Math.round((options?.timeout || 0) / 1000);
202
+ resolve({
203
+ output: `${fullOutput}\n\nCommand timed out after ${timeoutSecs} seconds`,
204
+ exitCode: undefined,
205
+ cancelled: true,
206
+ truncated: truncationResult.truncated,
207
+ fullOutputPath: tempFilePath,
208
+ });
209
+ return;
210
+ }
211
+
172
212
  // Non-zero exit codes or signal-killed processes are considered cancelled if killed via signal
173
213
  const cancelled = exitCode === null || (exitCode !== 0 && (options?.signal?.aborted ?? false));
174
214
 
@@ -180,11 +220,11 @@ export function executeBash(command: string, options?: BashExecutorOptions): Pro
180
220
  fullOutputPath: tempFilePath,
181
221
  });
182
222
  } catch (err) {
183
- // Clean up abort listener
223
+ // Clean up
224
+ if (timeoutHandle) clearTimeout(timeoutHandle);
184
225
  if (options?.signal) {
185
226
  options.signal.removeEventListener("abort", abortHandler);
186
227
  }
187
-
188
228
  if (tempFileStream) {
189
229
  tempFileStream.end();
190
230
  }