feat(coding-agent): added persistent shell sessions
- Added persistent shell session support for bash tool with environment variable preservation across commands. - Added shellForceBasic setting to force bash/sh even if user's default shell is different (default: true). - Added OMP_SHELL_PERSIST environment variable to control persistent shell behavior (set to 0 to disable). - Restructured system prompt with coordinator-specific guidance for parallel task delegation.
This commit is contained in:
@@ -1,484 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { Agent } from "@oh-my-pi/pi-agent-core";
|
||||
import type { AssistantMessage, Model, ToolResultMessage, UserMessage } from "@oh-my-pi/pi-ai";
|
||||
import { getModel } from "@oh-my-pi/pi-ai";
|
||||
import { calculateTool } from "./utils/calculate";
|
||||
|
||||
async function basicPrompt(model: Model<any>) {
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful assistant. Keep your responses concise.",
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
tools: [],
|
||||
},
|
||||
});
|
||||
|
||||
await agent.prompt("What is 2+2? Answer with just the number.");
|
||||
|
||||
expect(agent.state.isStreaming).toBe(false);
|
||||
expect(agent.state.messages.length).toBe(2);
|
||||
expect(agent.state.messages[0].role).toBe("user");
|
||||
expect(agent.state.messages[1].role).toBe("assistant");
|
||||
|
||||
const assistantMessage = agent.state.messages[1];
|
||||
if (assistantMessage.role !== "assistant") throw new Error("Expected assistant message");
|
||||
expect(assistantMessage.content.length).toBeGreaterThan(0);
|
||||
|
||||
const textContent = assistantMessage.content.find(c => c.type === "text");
|
||||
expect(textContent).toBeDefined();
|
||||
if (textContent?.type !== "text") throw new Error("Expected text content");
|
||||
expect(textContent.text).toContain("4");
|
||||
}
|
||||
|
||||
async function toolExecution(model: Model<any>) {
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful assistant. Always use the calculator tool for math.",
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
tools: [calculateTool],
|
||||
},
|
||||
});
|
||||
|
||||
await agent.prompt("Calculate 123 * 456 using the calculator tool.");
|
||||
|
||||
expect(agent.state.isStreaming).toBe(false);
|
||||
expect(agent.state.messages.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const toolResultMsg = agent.state.messages.find(m => m.role === "toolResult");
|
||||
expect(toolResultMsg).toBeDefined();
|
||||
if (toolResultMsg?.role !== "toolResult") throw new Error("Expected tool result message");
|
||||
const textContent =
|
||||
toolResultMsg.content
|
||||
?.filter(c => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join("\n") || "";
|
||||
expect(textContent).toBeDefined();
|
||||
|
||||
const expectedResult = 123 * 456;
|
||||
expect(textContent).toContain(String(expectedResult));
|
||||
|
||||
const finalMessage = agent.state.messages[agent.state.messages.length - 1];
|
||||
if (finalMessage.role !== "assistant") throw new Error("Expected final assistant message");
|
||||
const finalText = finalMessage.content.find(c => c.type === "text");
|
||||
expect(finalText).toBeDefined();
|
||||
if (finalText?.type !== "text") throw new Error("Expected text content");
|
||||
// Check for number with or without comma formatting
|
||||
const hasNumber =
|
||||
finalText.text.includes(String(expectedResult)) ||
|
||||
finalText.text.includes("56,088") ||
|
||||
finalText.text.includes("56088");
|
||||
expect(hasNumber).toBe(true);
|
||||
}
|
||||
|
||||
async function abortExecution(model: Model<any>) {
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
tools: [calculateTool],
|
||||
},
|
||||
});
|
||||
|
||||
const promptPromise = agent.prompt("Calculate 100 * 200, then 300 * 400, then sum the results.");
|
||||
|
||||
setTimeout(() => {
|
||||
agent.abort();
|
||||
}, 100);
|
||||
|
||||
await promptPromise;
|
||||
|
||||
expect(agent.state.isStreaming).toBe(false);
|
||||
expect(agent.state.messages.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const lastMessage = agent.state.messages[agent.state.messages.length - 1];
|
||||
if (lastMessage.role !== "assistant") throw new Error("Expected assistant message");
|
||||
expect(lastMessage.stopReason).toBe("aborted");
|
||||
expect(lastMessage.errorMessage).toBeDefined();
|
||||
expect(agent.state.error).toBeDefined();
|
||||
expect(agent.state.error).toBe(lastMessage.errorMessage);
|
||||
}
|
||||
|
||||
async function stateUpdates(model: Model<any>) {
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
tools: [],
|
||||
},
|
||||
});
|
||||
|
||||
const events: Array<string> = [];
|
||||
|
||||
agent.subscribe(event => {
|
||||
events.push(event.type);
|
||||
});
|
||||
|
||||
await agent.prompt("Count from 1 to 5.");
|
||||
|
||||
// Should have received lifecycle events
|
||||
expect(events).toContain("agent_start");
|
||||
expect(events).toContain("agent_end");
|
||||
expect(events).toContain("message_start");
|
||||
expect(events).toContain("message_end");
|
||||
// May have message_update events during streaming
|
||||
const hasMessageUpdates = events.some(e => e === "message_update");
|
||||
expect(hasMessageUpdates).toBe(true);
|
||||
|
||||
// Check final state
|
||||
expect(agent.state.isStreaming).toBe(false);
|
||||
expect(agent.state.messages.length).toBe(2); // User message + assistant response
|
||||
}
|
||||
|
||||
async function multiTurnConversation(model: Model<any>) {
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
tools: [],
|
||||
},
|
||||
});
|
||||
|
||||
await agent.prompt("My name is Alice.");
|
||||
expect(agent.state.messages.length).toBe(2);
|
||||
|
||||
await agent.prompt("What is my name?");
|
||||
expect(agent.state.messages.length).toBe(4);
|
||||
|
||||
const lastMessage = agent.state.messages[3];
|
||||
if (lastMessage.role !== "assistant") throw new Error("Expected assistant message");
|
||||
const lastText = lastMessage.content.find(c => c.type === "text");
|
||||
if (lastText?.type !== "text") throw new Error("Expected text content");
|
||||
expect(lastText.text.toLowerCase()).toContain("alice");
|
||||
}
|
||||
|
||||
describe("Agent E2E Tests", () => {
|
||||
describe.skipIf(!process.env.GEMINI_API_KEY)("Google Provider (gemini-2.5-flash)", () => {
|
||||
const model = getModel("google", "gemini-2.5-flash");
|
||||
|
||||
it("should handle basic text prompt", async () => {
|
||||
await basicPrompt(model);
|
||||
});
|
||||
|
||||
it("should execute tools correctly", async () => {
|
||||
await toolExecution(model);
|
||||
});
|
||||
|
||||
it("should handle abort during execution", async () => {
|
||||
await abortExecution(model);
|
||||
});
|
||||
|
||||
it("should emit state updates during streaming", async () => {
|
||||
await stateUpdates(model);
|
||||
});
|
||||
|
||||
it("should maintain context across multiple turns", async () => {
|
||||
await multiTurnConversation(model);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Provider (gpt-4o-mini)", () => {
|
||||
const model = getModel("openai", "gpt-4o-mini");
|
||||
|
||||
it("should handle basic text prompt", async () => {
|
||||
await basicPrompt(model);
|
||||
});
|
||||
|
||||
it("should execute tools correctly", async () => {
|
||||
await toolExecution(model);
|
||||
});
|
||||
|
||||
it("should handle abort during execution", async () => {
|
||||
await abortExecution(model);
|
||||
});
|
||||
|
||||
it("should emit state updates during streaming", async () => {
|
||||
await stateUpdates(model);
|
||||
});
|
||||
|
||||
it("should maintain context across multiple turns", async () => {
|
||||
await multiTurnConversation(model);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Provider (claude-haiku-4-5)", () => {
|
||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||
|
||||
it("should handle basic text prompt", async () => {
|
||||
await basicPrompt(model);
|
||||
});
|
||||
|
||||
it("should execute tools correctly", async () => {
|
||||
await toolExecution(model);
|
||||
});
|
||||
|
||||
it("should handle abort during execution", async () => {
|
||||
await abortExecution(model);
|
||||
});
|
||||
|
||||
it("should emit state updates during streaming", async () => {
|
||||
await stateUpdates(model);
|
||||
});
|
||||
|
||||
it("should maintain context across multiple turns", async () => {
|
||||
await multiTurnConversation(model);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider (grok-3)", () => {
|
||||
const model = getModel("xai", "grok-3");
|
||||
|
||||
it("should handle basic text prompt", async () => {
|
||||
await basicPrompt(model);
|
||||
});
|
||||
|
||||
it("should execute tools correctly", async () => {
|
||||
await toolExecution(model);
|
||||
});
|
||||
|
||||
it("should handle abort during execution", async () => {
|
||||
await abortExecution(model);
|
||||
});
|
||||
|
||||
it("should emit state updates during streaming", async () => {
|
||||
await stateUpdates(model);
|
||||
});
|
||||
|
||||
it("should maintain context across multiple turns", async () => {
|
||||
await multiTurnConversation(model);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.GROQ_API_KEY)("Groq Provider (openai/gpt-oss-20b)", () => {
|
||||
const model = getModel("groq", "openai/gpt-oss-20b");
|
||||
|
||||
it("should handle basic text prompt", async () => {
|
||||
await basicPrompt(model);
|
||||
});
|
||||
|
||||
it("should execute tools correctly", async () => {
|
||||
await toolExecution(model);
|
||||
});
|
||||
|
||||
it("should handle abort during execution", async () => {
|
||||
await abortExecution(model);
|
||||
});
|
||||
|
||||
it("should emit state updates during streaming", async () => {
|
||||
await stateUpdates(model);
|
||||
});
|
||||
|
||||
it("should maintain context across multiple turns", async () => {
|
||||
await multiTurnConversation(model);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras Provider (gpt-oss-120b)", () => {
|
||||
const model = getModel("cerebras", "gpt-oss-120b");
|
||||
|
||||
it("should handle basic text prompt", async () => {
|
||||
await basicPrompt(model);
|
||||
});
|
||||
|
||||
it("should execute tools correctly", async () => {
|
||||
await toolExecution(model);
|
||||
});
|
||||
|
||||
it("should handle abort during execution", async () => {
|
||||
await abortExecution(model);
|
||||
});
|
||||
|
||||
it("should emit state updates during streaming", async () => {
|
||||
await stateUpdates(model);
|
||||
});
|
||||
|
||||
it("should maintain context across multiple turns", async () => {
|
||||
await multiTurnConversation(model);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider (glm-4.5-air)", () => {
|
||||
const model = getModel("zai", "glm-4.5-air");
|
||||
|
||||
it("should handle basic text prompt", async () => {
|
||||
await basicPrompt(model);
|
||||
});
|
||||
|
||||
it("should execute tools correctly", async () => {
|
||||
await toolExecution(model);
|
||||
});
|
||||
|
||||
it("should handle abort during execution", async () => {
|
||||
await abortExecution(model);
|
||||
});
|
||||
|
||||
it("should emit state updates during streaming", async () => {
|
||||
await stateUpdates(model);
|
||||
});
|
||||
|
||||
it("should maintain context across multiple turns", async () => {
|
||||
await multiTurnConversation(model);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent.continue()", () => {
|
||||
describe("validation", () => {
|
||||
it("should throw when no messages in context", async () => {
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt: "Test",
|
||||
model: getModel("anthropic", "claude-haiku-4-5"),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(agent.continue()).rejects.toThrow("No messages to continue from");
|
||||
});
|
||||
|
||||
it("should throw when last message is assistant", async () => {
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt: "Test",
|
||||
model: getModel("anthropic", "claude-haiku-4-5"),
|
||||
},
|
||||
});
|
||||
|
||||
const assistantMessage: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-haiku-4-5",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
agent.replaceMessages([assistantMessage]);
|
||||
|
||||
await expect(agent.continue()).rejects.toThrow("Cannot continue from message role: assistant");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("continue from user message", () => {
|
||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||
|
||||
it("should continue and get response when last message is user", async () => {
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful assistant. Follow instructions exactly.",
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
tools: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Manually add a user message without calling prompt()
|
||||
const userMessage: UserMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Say exactly: HELLO WORLD" }],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
agent.replaceMessages([userMessage]);
|
||||
|
||||
// Continue from the user message
|
||||
await agent.continue();
|
||||
|
||||
expect(agent.state.isStreaming).toBe(false);
|
||||
expect(agent.state.messages.length).toBe(2);
|
||||
expect(agent.state.messages[0].role).toBe("user");
|
||||
expect(agent.state.messages[1].role).toBe("assistant");
|
||||
|
||||
const assistantMsg = agent.state.messages[1] as AssistantMessage;
|
||||
const textContent = assistantMsg.content.find(c => c.type === "text");
|
||||
expect(textContent).toBeDefined();
|
||||
if (textContent?.type === "text") {
|
||||
expect(textContent.text.toUpperCase()).toContain("HELLO WORLD");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("continue from tool result", () => {
|
||||
const model = getModel("anthropic", "claude-haiku-4-5");
|
||||
|
||||
it("should continue and process tool results", async () => {
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt:
|
||||
"You are a helpful assistant. After getting a calculation result, state the answer clearly.",
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
tools: [calculateTool],
|
||||
},
|
||||
});
|
||||
|
||||
// Set up a conversation state as if tool was just executed
|
||||
const userMessage: UserMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "What is 5 + 3?" }],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const assistantMessage: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Let me calculate that." },
|
||||
{ type: "toolCall", id: "calc-1", name: "calculate", arguments: { expression: "5 + 3" } },
|
||||
],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-haiku-4-5",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const toolResult: ToolResultMessage = {
|
||||
role: "toolResult",
|
||||
toolCallId: "calc-1",
|
||||
toolName: "calculate",
|
||||
content: [{ type: "text", text: "5 + 3 = 8" }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
agent.replaceMessages([userMessage, assistantMessage, toolResult]);
|
||||
|
||||
// Continue from the tool result
|
||||
await agent.continue();
|
||||
|
||||
expect(agent.state.isStreaming).toBe(false);
|
||||
// Should have added an assistant response
|
||||
expect(agent.state.messages.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
const lastMessage = agent.state.messages[agent.state.messages.length - 1];
|
||||
expect(lastMessage.role).toBe("assistant");
|
||||
|
||||
if (lastMessage.role === "assistant") {
|
||||
const textContent = lastMessage.content
|
||||
.filter(c => c.type === "text")
|
||||
.map(c => (c as { type: "text"; text: string }).text)
|
||||
.join(" ");
|
||||
// Should mention 8 in the response
|
||||
expect(textContent).toMatch(/8/);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Persistent shell session support for bash tool with environment variable preservation across commands
|
||||
- New `shellForceBasic` setting to force bash/sh even if user's default shell is different (default: true)
|
||||
- New `OMP_SHELL_PERSIST` environment variable to control persistent shell behavior (set to 0 to disable)
|
||||
|
||||
### Changed
|
||||
|
||||
- Bash tool now reuses a persistent shell session by default on Unix systems for improved performance and state preservation
|
||||
- Replaced Bun file APIs with Node.js `fs` module for better cross-runtime compatibility
|
||||
- LSP configuration loading is now synchronous instead of async
|
||||
- Shell snapshot generation now sanitizes `BASH_ENV` and `ENV` variables to prevent shell exit issues
|
||||
- Shell snapshot caching now per-shell-binary instead of global to avoid cross-shell contamination
|
||||
- System prompt restructured with coordinator-specific guidance for parallel task delegation
|
||||
- Bash tool now reuses a persistent shell session by default on Unix. Set `OMP_SHELL_PERSIST=0` to disable or fall back to per-command execution on Windows/unsupported shells.
|
||||
- Added a shellForceBasic setting to force bash/sh and keep environment changes across bash commands (default: true).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Shell snapshots now filter unsafe bash options (onecmd, monitor, restricted) to prevent session exits
|
||||
- Git branch detection in status line now works synchronously without race conditions
|
||||
- Shell session initialization properly restores trap handlers and shell functions after command execution
|
||||
- Sanitized `BASH_ENV`/`ENV` during persistent shell startup and snapshot creation to prevent basic shells from exiting immediately.
|
||||
- Cached shell snapshots per shell binary to avoid sourcing zsh snapshots in bash sessions.
|
||||
- Filtered unsafe bash options (onecmd/monitor/restricted) out of shell snapshots to prevent session exits.
|
||||
|
||||
## [9.2.2] - 2026-01-31
|
||||
|
||||
|
||||
@@ -195,15 +195,10 @@ async function updateViaBinary(release: ReleaseInfo): Promise<void> {
|
||||
console.log(chalk.green(`\n${theme.status.success} Updated to ${release.version}`));
|
||||
console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
|
||||
} catch (err) {
|
||||
const [backupExists, execExists, tempExists] = await Promise.all([
|
||||
Bun.file(backupPath).exists(),
|
||||
Bun.file(execPath).exists(),
|
||||
Bun.file(tempPath).exists(),
|
||||
]);
|
||||
if (backupExists && !execExists) {
|
||||
if (fs.existsSync(backupPath) && !fs.existsSync(execPath)) {
|
||||
await fs.promises.rename(backupPath, execPath);
|
||||
}
|
||||
if (tempExists) {
|
||||
if (fs.existsSync(tempPath)) {
|
||||
await fs.promises.unlink(tempPath);
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { ChangelogBoundary } from "../../commit/types";
|
||||
|
||||
@@ -25,7 +26,7 @@ async function findNearestChangelog(cwd: string, filePath: string): Promise<stri
|
||||
const root = path.resolve(cwd);
|
||||
while (true) {
|
||||
const candidate = path.resolve(current, CHANGELOG_NAME);
|
||||
if (await Bun.file(candidate).exists()) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
if (current === root) return null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { Api, Model } from "@oh-my-pi/pi-ai";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
@@ -108,7 +109,7 @@ export async function applyChangelogProposals({
|
||||
)
|
||||
continue;
|
||||
onProgress?.(`Applying entries for ${proposal.path}...`);
|
||||
const exists = await Bun.file(proposal.path).exists();
|
||||
const exists = fs.existsSync(proposal.path);
|
||||
if (!exists) {
|
||||
logger.warn("commit changelog path missing", { path: proposal.path });
|
||||
continue;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import Handlebars from "handlebars";
|
||||
@@ -435,7 +436,7 @@ async function loadTemplatesFromDir(
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(await Bun.file(dir).exists())) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
logger.warn("Failed to scan prompt templates directory", { dir, error: String(error) });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
|
||||
import { YAML } from "bun";
|
||||
@@ -222,6 +222,7 @@ export interface Settings {
|
||||
retry?: RetrySettings;
|
||||
hideThinkingBlock?: boolean;
|
||||
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
|
||||
shellForceBasic?: boolean; // Force bash/sh even if user's default shell is different
|
||||
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
|
||||
startup?: StartupSettings;
|
||||
doubleEscapeAction?: "branch" | "tree"; // Action for double-escape with empty editor (default: "tree")
|
||||
@@ -635,7 +636,7 @@ export class SettingsManager {
|
||||
migrated = true;
|
||||
// Backup settings.json
|
||||
try {
|
||||
await fs.rename(settingsJsonPath, `${settingsJsonPath}.bak`);
|
||||
fs.renameSync(settingsJsonPath, `${settingsJsonPath}.bak`);
|
||||
} catch (error) {
|
||||
logger.warn("SettingsManager failed to backup settings.json", { error: String(error) });
|
||||
}
|
||||
@@ -1061,12 +1062,22 @@ export class SettingsManager {
|
||||
return this.settings.shellPath;
|
||||
}
|
||||
|
||||
getShellForceBasic(): boolean {
|
||||
return this.settings.shellForceBasic ?? true;
|
||||
}
|
||||
|
||||
async setShellPath(path: string | undefined): Promise<void> {
|
||||
this.globalSettings.shellPath = path;
|
||||
this.markModified("shellPath");
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async setShellForceBasic(force: boolean): Promise<void> {
|
||||
this.globalSettings.shellForceBasic = force;
|
||||
this.markModified("shellForceBasic");
|
||||
await this.save();
|
||||
}
|
||||
|
||||
getCollapseChangelog(): boolean {
|
||||
return this.settings.collapseChangelog ?? false;
|
||||
}
|
||||
@@ -1963,7 +1974,13 @@ export class SettingsManager {
|
||||
* Gets the shell configuration
|
||||
* @returns The shell configuration
|
||||
*/
|
||||
async getShellConfig() {
|
||||
getShellConfig() {
|
||||
if (this.getShellForceBasic()) {
|
||||
const basicShell = resolveBasicShell();
|
||||
if (basicShell) {
|
||||
return procmgr.getShellConfig(basicShell);
|
||||
}
|
||||
}
|
||||
const shell = this.getShellPath();
|
||||
return procmgr.getShellConfig(shell);
|
||||
}
|
||||
@@ -1977,3 +1994,22 @@ export class SettingsManager {
|
||||
return settings.getShellConfig();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBasicShell(): string | undefined {
|
||||
const searchPaths = ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"];
|
||||
const candidates = ["bash", "sh"];
|
||||
|
||||
for (const name of candidates) {
|
||||
for (const dir of searchPaths) {
|
||||
const fullPath = path.join(dir, name);
|
||||
if (fs.existsSync(fullPath)) return fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of ["bash", "bash.exe", "sh", "sh.exe"]) {
|
||||
const resolved = Bun.which(name);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Exception, ptree } from "@oh-my-pi/pi-utils";
|
||||
import { SettingsManager } from "../config/settings-manager";
|
||||
import { OutputSink } from "../session/streaming-output";
|
||||
import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot";
|
||||
import { executeShellCommand } from "./shell-session";
|
||||
|
||||
export interface BashExecutorOptions {
|
||||
cwd?: string;
|
||||
@@ -34,13 +35,62 @@ export interface BashResult {
|
||||
|
||||
export async function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
|
||||
const { shell, args, env, prefix } = await SettingsManager.getGlobalShellConfig();
|
||||
const snapshotPath = await getOrCreateSnapshot(shell, env);
|
||||
|
||||
if (shouldUsePersistentShell(shell)) {
|
||||
return await executeShellCommand({ shell, env, prefix, snapshotPath }, command, {
|
||||
cwd: options?.cwd,
|
||||
timeout: options?.timeout,
|
||||
signal: options?.signal,
|
||||
onChunk: options?.onChunk,
|
||||
env: options?.env,
|
||||
artifactPath: options?.artifactPath,
|
||||
artifactId: options?.artifactId,
|
||||
});
|
||||
}
|
||||
|
||||
return await executeBashOnce(command, options, { shell, args, env, prefix, snapshotPath });
|
||||
}
|
||||
|
||||
function shouldUsePersistentShell(shell: string): boolean {
|
||||
const flag = parseEnvFlag(process.env.OMP_SHELL_PERSIST);
|
||||
if (flag !== undefined) return flag;
|
||||
if (process.platform === "win32") return false;
|
||||
const normalized = shell.toLowerCase();
|
||||
return (
|
||||
normalized.includes("bash") ||
|
||||
normalized.includes("zsh") ||
|
||||
normalized.includes("fish") ||
|
||||
normalized.endsWith("/sh") ||
|
||||
normalized.endsWith("\\\\sh") ||
|
||||
normalized.endsWith("sh")
|
||||
);
|
||||
}
|
||||
|
||||
function parseEnvFlag(value: string | undefined): boolean | undefined {
|
||||
if (!value) return undefined;
|
||||
const normalized = value.toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
||||
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function executeBashOnce(
|
||||
command: string,
|
||||
options: BashExecutorOptions | undefined,
|
||||
config: {
|
||||
shell: string;
|
||||
args: string[];
|
||||
env: Record<string, string | undefined>;
|
||||
prefix?: string;
|
||||
snapshotPath: string | null;
|
||||
},
|
||||
): Promise<BashResult> {
|
||||
const { shell, args, env, prefix, snapshotPath } = config;
|
||||
|
||||
// Merge additional env vars if provided
|
||||
const finalEnv = options?.env ? { ...env, ...options.env } : env;
|
||||
|
||||
const snapshotPath = await getOrCreateSnapshot(shell, env);
|
||||
const snapshotPrefix = getSnapshotSourceCommand(snapshotPath);
|
||||
|
||||
const prefixedCommand = prefix ? `${prefix} ${command}` : command;
|
||||
const finalCommand = `${snapshotPrefix}${prefixedCommand}`;
|
||||
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* Persistent shell session executor for streaming bash tool output.
|
||||
*/
|
||||
import * as crypto from "node:crypto";
|
||||
import { logger, postmortem, ptree } from "@oh-my-pi/pi-utils";
|
||||
import { OutputSink, type OutputSummary } from "../session/streaming-output";
|
||||
|
||||
export interface ShellSessionConfig {
|
||||
shell: string;
|
||||
env: Record<string, string | undefined>;
|
||||
prefix?: string;
|
||||
snapshotPath: string | null;
|
||||
}
|
||||
|
||||
export interface ShellCommandOptions {
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
signal?: AbortSignal;
|
||||
onChunk?: (chunk: string) => void;
|
||||
env?: Record<string, string>;
|
||||
artifactPath?: string;
|
||||
artifactId?: string;
|
||||
}
|
||||
|
||||
export interface ShellCommandResult extends OutputSummary {
|
||||
exitCode: number | undefined;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
const MARKER_PREFIX = "__OMP_CMD_DONE__";
|
||||
const MARKER_TAIL_MAX = 128;
|
||||
const ABORT_GRACE_MS = 1500;
|
||||
const IS_WINDOWS = process.platform === "win32";
|
||||
|
||||
interface RunningCommand {
|
||||
marker: string;
|
||||
markerSentinel: string;
|
||||
sink: OutputSink;
|
||||
resolve: (result: ShellCommandResult) => void;
|
||||
done: Promise<ShellCommandResult>;
|
||||
cancelled: boolean;
|
||||
abortReason?: "timeout" | "signal";
|
||||
abortNotice?: string;
|
||||
abortListener?: () => void;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
function escapePosix(value: string): string {
|
||||
return `'${value.split("'").join("'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
function isFishShell(shell: string): boolean {
|
||||
return shell.includes("fish");
|
||||
}
|
||||
|
||||
function buildEnvExports(env: Record<string, string> | undefined, fish: boolean): string {
|
||||
if (!env) return "";
|
||||
const entries = Object.entries(env).filter(([, value]) => value !== undefined);
|
||||
if (entries.length === 0) return "";
|
||||
if (fish) {
|
||||
return entries.map(([key, value]) => `set -lx ${key} ${escapePosix(value)}`).join("\n");
|
||||
}
|
||||
return entries.map(([key, value]) => `export ${key}=${escapePosix(value)}`).join("\n");
|
||||
}
|
||||
|
||||
function buildPosixCommandScript(
|
||||
command: string,
|
||||
cwd: string | undefined,
|
||||
prefix: string | undefined,
|
||||
marker: string,
|
||||
commandEnv: Record<string, string> | undefined,
|
||||
): string {
|
||||
const envExports = buildEnvExports(commandEnv, false);
|
||||
const commandLine = prefix ? `${prefix} ${command}` : command;
|
||||
const lines: string[] = [
|
||||
"__omp_restore_errexit=0",
|
||||
"case $- in *e*) __omp_restore_errexit=1 ;; esac",
|
||||
"set +e",
|
||||
"__omp_prev_trap_int=$(trap -p INT 2>/dev/null || true)",
|
||||
"trap - INT",
|
||||
"__omp_prev_exit=",
|
||||
"__omp_prev_logout=",
|
||||
"__omp_prev_exec=",
|
||||
"if command -v typeset >/dev/null 2>&1; then __omp_prev_exit=$(typeset -f exit 2>/dev/null || true); fi",
|
||||
"if command -v typeset >/dev/null 2>&1; then __omp_prev_logout=$(typeset -f logout 2>/dev/null || true); fi",
|
||||
"if command -v typeset >/dev/null 2>&1; then __omp_prev_exec=$(typeset -f exec 2>/dev/null || true); fi",
|
||||
'exit() { if [ -n "$1" ]; then return "$1"; else return 0; fi; }',
|
||||
'logout() { if [ -n "$1" ]; then return "$1"; else return 0; fi; }',
|
||||
'exec() { command "$@"; return $?; }',
|
||||
];
|
||||
if (envExports) lines.push(envExports);
|
||||
if (cwd) lines.push(`cd -- ${escapePosix(cwd)}`);
|
||||
lines.push(commandLine.length > 0 ? commandLine : ":");
|
||||
lines.push("__omp_status=$?");
|
||||
lines.push("unset -f exit logout exec 2>/dev/null");
|
||||
lines.push('if [ -n "$__omp_prev_exit" ]; then eval "$__omp_prev_exit"; fi');
|
||||
lines.push('if [ -n "$__omp_prev_logout" ]; then eval "$__omp_prev_logout"; fi');
|
||||
lines.push('if [ -n "$__omp_prev_exec" ]; then eval "$__omp_prev_exec"; fi');
|
||||
lines.push('if [ -n "$__omp_prev_trap_int" ]; then eval "$__omp_prev_trap_int"; else trap - INT; fi');
|
||||
lines.push("unset __omp_prev_trap_int");
|
||||
lines.push("unset __omp_prev_exit __omp_prev_logout __omp_prev_exec");
|
||||
lines.push('if [ "$__omp_restore_errexit" -eq 1 ]; then set -e; fi');
|
||||
lines.push("unset __omp_restore_errexit");
|
||||
lines.push(`printf '\\n${marker}%d\\n' "$__omp_status"`);
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function buildFishCommandScript(
|
||||
command: string,
|
||||
cwd: string | undefined,
|
||||
prefix: string | undefined,
|
||||
marker: string,
|
||||
commandEnv: Record<string, string> | undefined,
|
||||
): string {
|
||||
const envExports = buildEnvExports(commandEnv, true);
|
||||
const commandLine = prefix ? `${prefix} ${command}` : command;
|
||||
const lines: string[] = [
|
||||
"begin",
|
||||
"functions -e __omp_prev_exit 2>/dev/null",
|
||||
"functions -e __omp_prev_logout 2>/dev/null",
|
||||
"functions -e __omp_prev_exec 2>/dev/null",
|
||||
"functions -q exit; and functions -c exit __omp_prev_exit",
|
||||
"functions -q logout; and functions -c logout __omp_prev_logout",
|
||||
"functions -q exec; and functions -c exec __omp_prev_exec",
|
||||
"function exit",
|
||||
" if test (count $argv) -gt 0",
|
||||
" set -g __omp_exit_code $argv[1]",
|
||||
" else",
|
||||
" set -g __omp_exit_code 0",
|
||||
" end",
|
||||
" return $__omp_exit_code",
|
||||
"end",
|
||||
"function logout",
|
||||
" if test (count $argv) -gt 0",
|
||||
" set -g __omp_exit_code $argv[1]",
|
||||
" else",
|
||||
" set -g __omp_exit_code 0",
|
||||
" end",
|
||||
" return $__omp_exit_code",
|
||||
"end",
|
||||
"function exec",
|
||||
" command $argv",
|
||||
" return $status",
|
||||
"end",
|
||||
];
|
||||
if (envExports) lines.push(envExports);
|
||||
if (cwd) lines.push(`cd -- ${escapePosix(cwd)}`);
|
||||
lines.push(commandLine.length > 0 ? commandLine : ":");
|
||||
lines.push("if set -q __omp_exit_code");
|
||||
lines.push(" set -l __omp_status $__omp_exit_code");
|
||||
lines.push(" set -e __omp_exit_code");
|
||||
lines.push("else");
|
||||
lines.push(" set -l __omp_status $status");
|
||||
lines.push("end");
|
||||
lines.push("functions -e exit logout exec");
|
||||
lines.push("functions -q __omp_prev_exit; and functions -c __omp_prev_exit exit; and functions -e __omp_prev_exit");
|
||||
lines.push(
|
||||
"functions -q __omp_prev_logout; and functions -c __omp_prev_logout logout; and functions -e __omp_prev_logout",
|
||||
);
|
||||
lines.push("functions -q __omp_prev_exec; and functions -c __omp_prev_exec exec; and functions -e __omp_prev_exec");
|
||||
lines.push(`printf "\\n${marker}%d\\n" $__omp_status`);
|
||||
lines.push("end");
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function getSessionArgs(shell: string, snapshotPath: string | null): string[] {
|
||||
if (snapshotPath) return [];
|
||||
const noLogin = process.env.OMP_BASH_NO_LOGIN || process.env.CLAUDE_BASH_NO_LOGIN;
|
||||
if (noLogin) return [];
|
||||
if (shell.includes("bash") || shell.includes("zsh") || shell.includes("fish")) return ["-l"];
|
||||
return [];
|
||||
}
|
||||
|
||||
function serializeEnv(env: Record<string, string | undefined>): string {
|
||||
const entries = Object.entries(env).filter(([, value]) => value !== undefined);
|
||||
entries.sort(([a], [b]) => a.localeCompare(b));
|
||||
return entries.map(([key, value]) => `${key}=${value}`).join("\n");
|
||||
}
|
||||
|
||||
function sanitizePersistentEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||
const sanitized = { ...env };
|
||||
delete sanitized.BASH_ENV;
|
||||
delete sanitized.ENV;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
class ShellSession {
|
||||
#child: ReturnType<typeof ptree.spawn<"pipe">> | null = null;
|
||||
#stdinWriter: WritableStreamDefaultWriter<Uint8Array> | Bun.FileSink | null = null;
|
||||
#buffer = "";
|
||||
#queue: Promise<void> = Promise.resolve();
|
||||
#chunkQueue: Promise<void> = Promise.resolve();
|
||||
#current: RunningCommand | null = null;
|
||||
#startPromise: Promise<void> | null = null;
|
||||
#closed = false;
|
||||
#encoder = new TextEncoder();
|
||||
#lastExitCode: number | null | undefined = undefined;
|
||||
|
||||
constructor(private readonly config: ShellSessionConfig) {}
|
||||
|
||||
async execute(command: string, options: ShellCommandOptions): Promise<ShellCommandResult> {
|
||||
const run = async () => {
|
||||
try {
|
||||
await this.#start();
|
||||
return await this.#runCommand(command, options);
|
||||
} catch (error) {
|
||||
if (this.#shouldRestart(error)) {
|
||||
await this.#terminateSession();
|
||||
await this.#start();
|
||||
return await this.#runCommand(command, options);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const queued = this.#queue.then(run, run);
|
||||
this.#queue = queued.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return queued;
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.#closed = true;
|
||||
const child = this.#child;
|
||||
this.#child = null;
|
||||
this.#stdinWriter = null;
|
||||
if (child) {
|
||||
child.kill();
|
||||
await child.exited.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async #start(): Promise<void> {
|
||||
if (this.#closed) {
|
||||
throw new Error("Shell session is closed");
|
||||
}
|
||||
if (this.#startPromise) return this.#startPromise;
|
||||
this.#startPromise = this.#spawnShell().catch(error => {
|
||||
this.#startPromise = null;
|
||||
throw error;
|
||||
});
|
||||
return this.#startPromise;
|
||||
}
|
||||
|
||||
async #spawnShell(): Promise<void> {
|
||||
const args = getSessionArgs(this.config.shell, this.config.snapshotPath);
|
||||
this.#child = ptree.spawn([this.config.shell, ...args], {
|
||||
stdin: "pipe",
|
||||
env: this.config.env,
|
||||
detached: !IS_WINDOWS,
|
||||
});
|
||||
|
||||
if (this.#child.proc.exitCode !== null) {
|
||||
this.#lastExitCode = this.#child.proc.exitCode;
|
||||
throw new Error(`Shell exited immediately with code ${this.#child.proc.exitCode}`);
|
||||
}
|
||||
|
||||
const stdin = this.#child.stdin;
|
||||
if (stdin && typeof stdin === "object" && "getWriter" in stdin) {
|
||||
this.#stdinWriter = (stdin as unknown as WritableStream<Uint8Array>).getWriter();
|
||||
} else {
|
||||
this.#stdinWriter = stdin as Bun.FileSink;
|
||||
}
|
||||
this.#attachStreams(this.#child);
|
||||
this.#child.exited.then(code => this.#handleShellExit(code)).catch(() => this.#handleShellExit(null));
|
||||
|
||||
const initCommand = this.#buildInitCommand();
|
||||
if (initCommand) {
|
||||
await this.#runCommand(initCommand, {});
|
||||
}
|
||||
}
|
||||
|
||||
#buildInitCommand(): string | null {
|
||||
if (!this.config.snapshotPath) return null;
|
||||
const snapshotPath = escapePosix(this.config.snapshotPath);
|
||||
if (isFishShell(this.config.shell)) {
|
||||
return `source ${snapshotPath}`;
|
||||
}
|
||||
return `source ${snapshotPath} 2>/dev/null`;
|
||||
}
|
||||
|
||||
#attachStreams(child: ReturnType<typeof ptree.spawn<"pipe">>): void {
|
||||
const readStream = async (stream: ReadableStream<Uint8Array>) => {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
const text = decoder.decode(value, { stream: true });
|
||||
if (text) {
|
||||
await this.#enqueueChunk(text);
|
||||
}
|
||||
}
|
||||
const remaining = decoder.decode();
|
||||
if (remaining) {
|
||||
await this.#enqueueChunk(remaining);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {}
|
||||
reader.releaseLock();
|
||||
}
|
||||
};
|
||||
|
||||
void readStream(child.stdout);
|
||||
void readStream(child.stderr);
|
||||
}
|
||||
|
||||
async #enqueueChunk(text: string): Promise<void> {
|
||||
this.#chunkQueue = this.#chunkQueue.then(() => this.#processChunk(text));
|
||||
return this.#chunkQueue;
|
||||
}
|
||||
|
||||
async #processChunk(text: string): Promise<void> {
|
||||
const running = this.#current;
|
||||
if (!running) return;
|
||||
this.#buffer += text;
|
||||
|
||||
const sentinel = running.markerSentinel;
|
||||
while (this.#buffer.length > 0) {
|
||||
const markerIndex = this.#buffer.indexOf(sentinel);
|
||||
if (markerIndex === -1) {
|
||||
const lastNewline = this.#buffer.lastIndexOf("\n");
|
||||
if (lastNewline > -1) {
|
||||
const tail = this.#buffer.slice(lastNewline);
|
||||
const flushLength = tail.length <= MARKER_TAIL_MAX ? lastNewline : this.#buffer.length - MARKER_TAIL_MAX;
|
||||
if (flushLength > 0) {
|
||||
await running.sink.push(this.#buffer.slice(0, flushLength));
|
||||
this.#buffer = this.#buffer.slice(flushLength);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const flushLength = Math.max(0, this.#buffer.length - Math.min(sentinel.length, MARKER_TAIL_MAX));
|
||||
if (flushLength > 0) {
|
||||
await running.sink.push(this.#buffer.slice(0, flushLength));
|
||||
this.#buffer = this.#buffer.slice(flushLength);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (markerIndex > 0) {
|
||||
await running.sink.push(this.#buffer.slice(0, markerIndex));
|
||||
}
|
||||
|
||||
const markerValueStart = markerIndex + sentinel.length;
|
||||
const lineEnd = this.#buffer.indexOf("\n", markerValueStart);
|
||||
if (lineEnd === -1) {
|
||||
this.#buffer = this.#buffer.slice(markerIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
const exitText = this.#buffer.slice(markerValueStart, lineEnd).trim();
|
||||
const exitCode = Number.parseInt(exitText, 10);
|
||||
this.#buffer = this.#buffer.slice(lineEnd + 1);
|
||||
await this.#finishCommand(running, Number.isFinite(exitCode) ? exitCode : undefined);
|
||||
this.#buffer = "";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async #runCommand(command: string, options: ShellCommandOptions): Promise<ShellCommandResult> {
|
||||
if (!this.#child || !this.#stdinWriter) {
|
||||
const exitInfo = this.#lastExitCode === undefined ? "unknown" : String(this.#lastExitCode);
|
||||
throw new Error(`Shell session not started (shell=${this.config.shell}, exit=${exitInfo})`);
|
||||
}
|
||||
this.#buffer = "";
|
||||
|
||||
const markerId = crypto.randomUUID().replace(/-/g, "");
|
||||
const marker = `${MARKER_PREFIX}${markerId}__`;
|
||||
const markerSentinel = `\n${marker}`;
|
||||
|
||||
const sink = new OutputSink({
|
||||
onChunk: options.onChunk,
|
||||
artifactPath: options.artifactPath,
|
||||
artifactId: options.artifactId,
|
||||
});
|
||||
|
||||
const { promise, resolve } = Promise.withResolvers<ShellCommandResult>();
|
||||
const running: RunningCommand = {
|
||||
marker,
|
||||
markerSentinel,
|
||||
sink,
|
||||
resolve,
|
||||
done: promise,
|
||||
cancelled: false,
|
||||
completed: false,
|
||||
};
|
||||
|
||||
this.#current = running;
|
||||
|
||||
const timeoutSignal = options.timeout ? AbortSignal.timeout(options.timeout) : undefined;
|
||||
let timeoutFired = false;
|
||||
if (timeoutSignal) {
|
||||
timeoutSignal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
timeoutFired = true;
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
const combinedSignal = options.signal
|
||||
? AbortSignal.any(timeoutSignal ? [options.signal, timeoutSignal] : [options.signal])
|
||||
: timeoutSignal;
|
||||
|
||||
if (combinedSignal) {
|
||||
const onAbort = () => {
|
||||
void this.#abortCommand(running, timeoutFired ? "timeout" : "signal", options.timeout);
|
||||
};
|
||||
running.abortListener = () => combinedSignal.removeEventListener("abort", onAbort);
|
||||
if (combinedSignal.aborted) {
|
||||
void this.#abortCommand(running, timeoutFired ? "timeout" : "signal", options.timeout);
|
||||
} else {
|
||||
combinedSignal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const script = isFishShell(this.config.shell)
|
||||
? buildFishCommandScript(command, options.cwd, this.config.prefix, marker, options.env)
|
||||
: buildPosixCommandScript(command, options.cwd, this.config.prefix, marker, options.env);
|
||||
await this.#writeToStdin(script);
|
||||
} catch (error) {
|
||||
await this.#handleWriteFailure(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
|
||||
return await promise;
|
||||
}
|
||||
|
||||
async #finishCommand(running: RunningCommand, exitCode: number | undefined): Promise<void> {
|
||||
if (running.completed) return;
|
||||
running.completed = true;
|
||||
running.abortListener?.();
|
||||
this.#current = null;
|
||||
const summary = await running.sink.dump(running.cancelled ? running.abortNotice : undefined);
|
||||
running.resolve({
|
||||
exitCode: running.cancelled ? undefined : exitCode,
|
||||
cancelled: running.cancelled,
|
||||
...summary,
|
||||
});
|
||||
}
|
||||
|
||||
async #abortCommand(
|
||||
running: RunningCommand,
|
||||
reason: "timeout" | "signal",
|
||||
timeoutMs: number | undefined,
|
||||
): Promise<void> {
|
||||
if (running.completed) return;
|
||||
running.cancelled = true;
|
||||
running.abortReason = reason;
|
||||
const notice =
|
||||
reason === "timeout" && timeoutMs
|
||||
? `Command timed out after ${Math.round(timeoutMs / 1000)} seconds`
|
||||
: "Command cancelled";
|
||||
running.abortNotice = notice;
|
||||
|
||||
await this.#sendInterrupt();
|
||||
const completed = await Promise.race([
|
||||
running.done.then(
|
||||
() => true,
|
||||
() => true,
|
||||
),
|
||||
Bun.sleep(ABORT_GRACE_MS).then(() => false),
|
||||
]);
|
||||
if (completed) return;
|
||||
|
||||
await this.#terminateSession();
|
||||
if (running.completed) return;
|
||||
running.completed = true;
|
||||
running.abortListener?.();
|
||||
this.#current = null;
|
||||
const summary = await running.sink.dump(notice);
|
||||
running.resolve({
|
||||
exitCode: undefined,
|
||||
cancelled: true,
|
||||
...summary,
|
||||
});
|
||||
}
|
||||
|
||||
async #sendInterrupt(): Promise<void> {
|
||||
const child = this.#child;
|
||||
if (!child?.pid) return;
|
||||
try {
|
||||
if (IS_WINDOWS) {
|
||||
child.proc.kill("SIGINT");
|
||||
return;
|
||||
}
|
||||
if (child.isProcessGroup) {
|
||||
process.kill(-child.pid, "SIGINT");
|
||||
} else {
|
||||
process.kill(child.pid, "SIGINT");
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async #terminateSession(): Promise<void> {
|
||||
const child = this.#child;
|
||||
this.#child = null;
|
||||
this.#stdinWriter = null;
|
||||
this.#startPromise = null;
|
||||
if (child) {
|
||||
child.kill();
|
||||
await child.exited.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async #handleShellExit(exitCode: number | null): Promise<void> {
|
||||
const running = this.#current;
|
||||
this.#lastExitCode = exitCode;
|
||||
this.#child = null;
|
||||
this.#stdinWriter = null;
|
||||
this.#startPromise = null;
|
||||
this.#buffer = "";
|
||||
|
||||
if (!running || running.completed) return;
|
||||
running.cancelled = true;
|
||||
running.abortReason = "signal";
|
||||
running.completed = true;
|
||||
running.abortListener?.();
|
||||
this.#current = null;
|
||||
const summary = await running.sink.dump(running.abortNotice ?? "Shell session terminated");
|
||||
running.resolve({
|
||||
exitCode: undefined,
|
||||
cancelled: true,
|
||||
...summary,
|
||||
});
|
||||
}
|
||||
|
||||
async #handleWriteFailure(error: Error): Promise<void> {
|
||||
logger.warn("Shell session write failed", { error: error.message });
|
||||
await this.#terminateSession();
|
||||
throw error;
|
||||
}
|
||||
|
||||
#shouldRestart(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return (
|
||||
error.message.includes("Shell session not started") ||
|
||||
error.message.includes("Shell session stdin unavailable")
|
||||
);
|
||||
}
|
||||
|
||||
async #writeToStdin(script: string): Promise<void> {
|
||||
if (!this.#stdinWriter) {
|
||||
throw new Error("Shell session stdin unavailable");
|
||||
}
|
||||
const payload = this.#encoder.encode(script);
|
||||
const writer = this.#stdinWriter;
|
||||
await Promise.resolve(writer.write(payload));
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = new Map<string, ShellSession>();
|
||||
|
||||
function buildSessionKey(config: ShellSessionConfig): string {
|
||||
return [config.shell, config.prefix ?? "", config.snapshotPath ?? "", serializeEnv(config.env)].join("\n");
|
||||
}
|
||||
|
||||
export async function executeShellCommand(
|
||||
config: ShellSessionConfig,
|
||||
command: string,
|
||||
options: ShellCommandOptions,
|
||||
): Promise<ShellCommandResult> {
|
||||
const sanitizedConfig = { ...config, env: sanitizePersistentEnv(config.env) };
|
||||
const key = buildSessionKey(sanitizedConfig);
|
||||
let session = sessions.get(key);
|
||||
if (!session) {
|
||||
session = new ShellSession(sanitizedConfig);
|
||||
sessions.set(key, session);
|
||||
}
|
||||
return await session.execute(command, options);
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
buildPosixCommandScript,
|
||||
buildFishCommandScript,
|
||||
escapePosix,
|
||||
getSessionArgs,
|
||||
};
|
||||
|
||||
postmortem.register("shell-session", async () => {
|
||||
const active = Array.from(sessions.values());
|
||||
sessions.clear();
|
||||
await Promise.all(active.map(session => session.dispose()));
|
||||
});
|
||||
@@ -163,25 +163,25 @@ function filterEnv(env: Record<string, string | undefined>): Record<string, stri
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async function resolveVenvPath(cwd: string): Promise<string | null> {
|
||||
function resolveVenvPath(cwd: string): string | null {
|
||||
if (process.env.VIRTUAL_ENV) return process.env.VIRTUAL_ENV;
|
||||
const candidates = [path.join(cwd, ".venv"), path.join(cwd, "venv")];
|
||||
for (const candidate of candidates) {
|
||||
if (await Bun.file(candidate).exists()) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string | undefined>) {
|
||||
function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string | undefined>) {
|
||||
const env = { ...baseEnv };
|
||||
const venvPath = env.VIRTUAL_ENV ?? (await resolveVenvPath(cwd));
|
||||
const venvPath = env.VIRTUAL_ENV ?? resolveVenvPath(cwd);
|
||||
if (venvPath) {
|
||||
env.VIRTUAL_ENV = venvPath;
|
||||
const binDir = process.platform === "win32" ? path.join(venvPath, "Scripts") : path.join(venvPath, "bin");
|
||||
const pythonCandidate = path.join(binDir, process.platform === "win32" ? "python.exe" : "python");
|
||||
if (await Bun.file(pythonCandidate).exists()) {
|
||||
if (fs.existsSync(pythonCandidate)) {
|
||||
const pathKey = resolvePathKey(env);
|
||||
const currentPath = env[pathKey];
|
||||
env[pathKey] = currentPath ? `${binDir}${path.delimiter}${currentPath}` : binDir;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as fs from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import * as path from "node:path";
|
||||
import { logger, ptree } from "@oh-my-pi/pi-utils";
|
||||
@@ -235,25 +236,25 @@ function filterEnv(env: Record<string, string | undefined>): Record<string, stri
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async function resolveVenvPath(cwd: string): Promise<string | null> {
|
||||
function resolveVenvPath(cwd: string): string | null {
|
||||
if (process.env.VIRTUAL_ENV) return process.env.VIRTUAL_ENV;
|
||||
const candidates = [path.join(cwd, ".venv"), path.join(cwd, "venv")];
|
||||
for (const candidate of candidates) {
|
||||
if (await Bun.file(candidate).exists()) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string | undefined>) {
|
||||
function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string | undefined>) {
|
||||
const env = { ...baseEnv };
|
||||
const venvPath = env.VIRTUAL_ENV ?? (await resolveVenvPath(cwd));
|
||||
const venvPath = env.VIRTUAL_ENV ?? resolveVenvPath(cwd);
|
||||
if (venvPath) {
|
||||
env.VIRTUAL_ENV = venvPath;
|
||||
const binDir = process.platform === "win32" ? path.join(venvPath, "Scripts") : path.join(venvPath, "bin");
|
||||
const pythonCandidate = path.join(binDir, process.platform === "win32" ? "python.exe" : "python");
|
||||
if (await Bun.file(pythonCandidate).exists()) {
|
||||
if (fs.existsSync(pythonCandidate)) {
|
||||
const pathKey = resolvePathKey(env);
|
||||
const currentPath = env[pathKey];
|
||||
env[pathKey] = currentPath ? `${binDir}${path.delimiter}${currentPath}` : binDir;
|
||||
@@ -281,7 +282,7 @@ export async function checkPythonKernelAvailability(cwd: string): Promise<Python
|
||||
try {
|
||||
const { env } = await SettingsManager.getGlobalShellConfig();
|
||||
const baseEnv = filterEnv(env);
|
||||
const runtime = await resolvePythonRuntime(cwd, baseEnv);
|
||||
const runtime = resolvePythonRuntime(cwd, baseEnv);
|
||||
const checkScript =
|
||||
"import importlib.util,sys;sys.exit(0 if importlib.util.find_spec('kernel_gateway') and importlib.util.find_spec('ipykernel') else 1)";
|
||||
const result = await $`${runtime.pythonPath} -c ${checkScript}`.quiet().nothrow().cwd(cwd).env(runtime.env);
|
||||
@@ -613,7 +614,7 @@ export class PythonKernel {
|
||||
private static async startWithLocalGateway(options: KernelStartOptions): Promise<PythonKernel> {
|
||||
const { shell, env } = await SettingsManager.getGlobalShellConfig();
|
||||
const filteredEnv = filterEnv(env);
|
||||
const runtime = await resolvePythonRuntime(options.cwd, filteredEnv);
|
||||
const runtime = resolvePythonRuntime(options.cwd, filteredEnv);
|
||||
const snapshotPath = await getOrCreateSnapshot(shell, env).catch((err: unknown) => {
|
||||
logger.warn("Failed to resolve shell snapshot for Python kernel", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
@@ -84,13 +85,9 @@ function normalizeServerConfig(name: string, config: Partial<ServerConfig>): Ser
|
||||
};
|
||||
}
|
||||
|
||||
async function readConfigFile(filePath: string): Promise<NormalizedConfig | null> {
|
||||
function readConfigFile(filePath: string): NormalizedConfig | null {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
return null;
|
||||
}
|
||||
const content = await file.text();
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = parseConfigContent(content, filePath);
|
||||
return normalizeConfig(parsed);
|
||||
} catch {
|
||||
@@ -155,7 +152,7 @@ function applyRuntimeDefaults(servers: Record<string, ServerConfig>): Record<str
|
||||
/**
|
||||
* Check if any root marker file exists in the directory
|
||||
*/
|
||||
export async function hasRootMarkers(cwd: string, markers: string[]): Promise<boolean> {
|
||||
export function hasRootMarkers(cwd: string, markers: string[]): boolean {
|
||||
for (const marker of markers) {
|
||||
// Handle glob-like patterns (e.g., "*.cabal")
|
||||
if (marker.includes("*")) {
|
||||
@@ -170,7 +167,7 @@ export async function hasRootMarkers(cwd: string, markers: string[]): Promise<bo
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(cwd, marker);
|
||||
if (await Bun.file(filePath).exists()) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -207,12 +204,12 @@ const LOCAL_BIN_PATHS: Array<{ markers: string[]; binDir: string }> = [
|
||||
* @param cwd - Working directory to search from
|
||||
* @returns Absolute path to the executable, or null if not found
|
||||
*/
|
||||
export async function resolveCommand(command: string, cwd: string): Promise<string | null> {
|
||||
export function resolveCommand(command: string, cwd: string): string | null {
|
||||
// Check local bin directories based on project markers
|
||||
for (const { markers, binDir } of LOCAL_BIN_PATHS) {
|
||||
if (await hasRootMarkers(cwd, markers)) {
|
||||
if (hasRootMarkers(cwd, markers)) {
|
||||
const localPath = path.join(cwd, binDir, command);
|
||||
if (await Bun.file(localPath).exists()) {
|
||||
if (fs.existsSync(localPath)) {
|
||||
return localPath;
|
||||
}
|
||||
}
|
||||
@@ -290,7 +287,7 @@ function getConfigPaths(cwd: string): string[] {
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function loadConfig(cwd: string): Promise<LspConfig> {
|
||||
export function loadConfig(cwd: string): LspConfig {
|
||||
let mergedServers = coerceServerConfigs(DEFAULTS);
|
||||
|
||||
const configPaths = getConfigPaths(cwd).reverse();
|
||||
@@ -298,7 +295,7 @@ export async function loadConfig(cwd: string): Promise<LspConfig> {
|
||||
|
||||
let idleTimeoutMs: number | undefined;
|
||||
for (const configPath of configPaths) {
|
||||
const parsed = await readConfigFile(configPath);
|
||||
const parsed = readConfigFile(configPath);
|
||||
if (!parsed) continue;
|
||||
const hasServerOverrides = Object.keys(parsed.servers).length > 0;
|
||||
if (hasServerOverrides) {
|
||||
@@ -317,10 +314,10 @@ export async function loadConfig(cwd: string): Promise<LspConfig> {
|
||||
|
||||
for (const [name, config] of Object.entries(defaultsWithRuntime)) {
|
||||
// Check if project has root markers for this language
|
||||
if (!(await hasRootMarkers(cwd, config.rootMarkers))) continue;
|
||||
if (!hasRootMarkers(cwd, config.rootMarkers)) continue;
|
||||
|
||||
// Check if the language server binary is available (local or $PATH)
|
||||
const resolved = await resolveCommand(config.command, cwd);
|
||||
const resolved = resolveCommand(config.command, cwd);
|
||||
if (!resolved) continue;
|
||||
|
||||
detected[name] = { ...config, resolvedCommand: resolved };
|
||||
@@ -335,7 +332,7 @@ export async function loadConfig(cwd: string): Promise<LspConfig> {
|
||||
|
||||
for (const [name, config] of Object.entries(mergedWithRuntime)) {
|
||||
if (config.disabled) continue;
|
||||
const resolved = await resolveCommand(config.command, cwd);
|
||||
const resolved = resolveCommand(config.command, cwd);
|
||||
if (!resolved) continue;
|
||||
available[name] = { ...config, resolvedCommand: resolved };
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export interface LspWarmupOptions {
|
||||
* @returns Status of each server that was started
|
||||
*/
|
||||
export async function warmupLspServers(cwd: string, options?: LspWarmupOptions): Promise<LspWarmupResult> {
|
||||
const config = await loadConfig(cwd);
|
||||
const config = loadConfig(cwd);
|
||||
setIdleTimeout(config.idleTimeoutMs);
|
||||
const servers: LspWarmupResult["servers"] = [];
|
||||
const lspServers = getLspServers(config);
|
||||
@@ -198,10 +198,10 @@ async function notifyFileSaved(
|
||||
// Cache config per cwd to avoid repeated file I/O
|
||||
const configCache = new Map<string, LspConfig>();
|
||||
|
||||
async function getConfig(cwd: string): Promise<LspConfig> {
|
||||
function getConfig(cwd: string): LspConfig {
|
||||
let config = configCache.get(cwd);
|
||||
if (!config) {
|
||||
config = await loadConfig(cwd);
|
||||
config = loadConfig(cwd);
|
||||
setIdleTimeout(config.idleTimeoutMs);
|
||||
configCache.set(cwd, config);
|
||||
}
|
||||
@@ -827,7 +827,7 @@ async function runLspWritethrough(
|
||||
file?: BunFile,
|
||||
): Promise<FileDiagnosticsResult | undefined> {
|
||||
const { enableFormat, enableDiagnostics } = options;
|
||||
const config = await getConfig(cwd);
|
||||
const config = getConfig(cwd);
|
||||
const servers = getServersForFile(config, dst);
|
||||
if (servers.length === 0) {
|
||||
return writethroughNoop(dst, content, signal, file);
|
||||
@@ -996,7 +996,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
|
||||
include_declaration,
|
||||
} = params;
|
||||
|
||||
const config = await getConfig(this.session.cwd);
|
||||
const config = getConfig(this.session.cwd);
|
||||
|
||||
// Status action doesn't need a file
|
||||
if (action === "status") {
|
||||
|
||||
@@ -263,6 +263,15 @@ export const SETTINGS_DEFS: SettingDef[] = [
|
||||
get: sm => sm.getBashInterceptorEnabled(),
|
||||
set: (sm, v) => sm.setBashInterceptorEnabled(v),
|
||||
},
|
||||
{
|
||||
id: "shellForceBasic",
|
||||
tab: "tools",
|
||||
type: "boolean",
|
||||
label: "Force basic shell",
|
||||
description: "Use bash/sh even if your default shell is different",
|
||||
get: sm => sm.getShellForceBasic(),
|
||||
set: (sm, v) => sm.setShellForceBasic(v),
|
||||
},
|
||||
{
|
||||
id: "bashInterceptorSimpleLs",
|
||||
tab: "tools",
|
||||
|
||||
@@ -23,11 +23,11 @@ function sanitizeStatusText(text: string): string {
|
||||
}
|
||||
|
||||
/** Find the git root directory by walking up from cwd */
|
||||
async function findGitHeadPath(): Promise<string | null> {
|
||||
function findGitHeadPath(): string | null {
|
||||
let dir = process.cwd();
|
||||
while (true) {
|
||||
const gitHeadPath = path.join(dir, ".git", "HEAD");
|
||||
if (await Bun.file(gitHeadPath).exists()) {
|
||||
if (fs.existsSync(gitHeadPath)) {
|
||||
return gitHeadPath;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
@@ -103,20 +103,19 @@ export class StatusLineComponent implements Component {
|
||||
this.gitWatcher = null;
|
||||
}
|
||||
|
||||
findGitHeadPath().then(gitHeadPath => {
|
||||
if (!gitHeadPath) return;
|
||||
const gitHeadPath = findGitHeadPath();
|
||||
if (!gitHeadPath) return;
|
||||
|
||||
try {
|
||||
this.gitWatcher = fs.watch(gitHeadPath, () => {
|
||||
this.cachedBranch = undefined;
|
||||
if (this.onBranchChange) {
|
||||
this.onBranchChange();
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
});
|
||||
try {
|
||||
this.gitWatcher = fs.watch(gitHeadPath, () => {
|
||||
this.cachedBranch = undefined;
|
||||
if (this.onBranchChange) {
|
||||
this.onBranchChange();
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -135,25 +134,22 @@ export class StatusLineComponent implements Component {
|
||||
return this.cachedBranch;
|
||||
}
|
||||
|
||||
// Note: synchronous call to async function - will return undefined on first call
|
||||
// This is acceptable since it's a cached value that will update on next render
|
||||
findGitHeadPath().then(async gitHeadPath => {
|
||||
if (!gitHeadPath) {
|
||||
this.cachedBranch = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const content = (await Bun.file(gitHeadPath).text()).trim();
|
||||
const gitHeadPath = findGitHeadPath();
|
||||
if (!gitHeadPath) {
|
||||
this.cachedBranch = null;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(gitHeadPath, "utf8").trim();
|
||||
|
||||
if (content.startsWith("ref: refs/heads/")) {
|
||||
this.cachedBranch = content.slice(16);
|
||||
} else {
|
||||
this.cachedBranch = "detached";
|
||||
}
|
||||
} catch {
|
||||
this.cachedBranch = null;
|
||||
if (content.startsWith("ref: refs/heads/")) {
|
||||
this.cachedBranch = content.slice(16);
|
||||
} else {
|
||||
this.cachedBranch = "detached";
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
this.cachedBranch = null;
|
||||
}
|
||||
|
||||
return this.cachedBranch ?? null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* Applies parsed diff hunks to file content using fuzzy matching
|
||||
* for robust handling of whitespace and formatting differences.
|
||||
*/
|
||||
import * as fs from "node:fs/promises";
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { resolveToCwd } from "../tools/path-utils";
|
||||
import { DEFAULT_FUZZY_THRESHOLD, findClosestSequenceMatch, findContextLine, findMatch, seekSequence } from "./fuzzy";
|
||||
@@ -37,7 +38,7 @@ import { ApplyPatchError, normalizePatchInput } from "./types";
|
||||
/** Default filesystem implementation using Bun APIs */
|
||||
export const defaultFileSystem: FileSystem = {
|
||||
async exists(path: string): Promise<boolean> {
|
||||
return Bun.file(path).exists();
|
||||
return fs.existsSync(path);
|
||||
},
|
||||
async read(path: string): Promise<string> {
|
||||
return Bun.file(path).text();
|
||||
@@ -50,10 +51,10 @@ export const defaultFileSystem: FileSystem = {
|
||||
await Bun.write(path, content);
|
||||
},
|
||||
async delete(path: string): Promise<void> {
|
||||
await fs.unlink(path);
|
||||
await fs.promises.unlink(path);
|
||||
},
|
||||
async mkdir(path: string): Promise<void> {
|
||||
await fs.mkdir(path, { recursive: true });
|
||||
await fs.promises.mkdir(path, { recursive: true });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -21,9 +21,6 @@ Your judgment has been earned through failure and recovery.
|
||||
<field>
|
||||
You are entering a code field.
|
||||
|
||||
Code is frozen thought. The bugs live where the thinking stopped too soon.
|
||||
Tools are extensions of attention. Use them to see, not to assume.
|
||||
|
||||
Notice the completion reflex:
|
||||
- The urge to produce something that runs
|
||||
- The pattern-match to similar problems you've seen
|
||||
@@ -56,16 +53,6 @@ No apologies. No comfort where clarity belongs.
|
||||
Quote only what illuminates. The rest is noise.
|
||||
</stance>
|
||||
|
||||
<commitment>
|
||||
This matters. Get it right.
|
||||
|
||||
The work is not finished when you are tired.
|
||||
The work is finished when it is correct.
|
||||
- Complete the full request before yielding control.
|
||||
- Use tools for any fact that can be verified. If you cannot verify, say so.
|
||||
- When results conflict: investigate. When incomplete: iterate. When uncertain: re-run.
|
||||
</commitment>
|
||||
|
||||
{{#if systemPromptCustomization}}
|
||||
<context>
|
||||
{{systemPromptCustomization}}
|
||||
@@ -78,10 +65,6 @@ The work is finished when it is correct.
|
||||
|
||||
<protocol>
|
||||
## The right tool exists. Use it.
|
||||
|
||||
Every tool is a choice.
|
||||
The wrong choice is friction. The right choice is invisible.
|
||||
Reach for what fits.
|
||||
**Available tools:** {{#each tools}}{{#unless @first}}, {{/unless}}`{{this}}`{{/each}}
|
||||
{{#ifAny (includes tools "python") (includes tools "bash")}}
|
||||
### Tool precedence
|
||||
@@ -94,11 +77,6 @@ Reach for what fits.
|
||||
**Edit tool** for surgical text changes—not sed. But for moving/transforming large content, use `sd` or Python to avoid repeating content from context.
|
||||
{{/has}}
|
||||
|
||||
{{#has tools "python"}}
|
||||
The Python prelude has helpers for file I/O, search, batch operations, and text processing.
|
||||
Do not run bash then read output then run more bash. Just use Python.
|
||||
{{/has}}
|
||||
|
||||
<critical>
|
||||
Never use Python or Bash when a specialized tool exists.
|
||||
`read` not cat/open(), `write` not cat>/echo>, `grep` not bash grep/re, `find` not bash find/glob, `ls` not bash ls/os.listdir, `edit` not sed.
|
||||
@@ -161,29 +139,6 @@ Continue non-destructively—someone else's work may live there.
|
||||
</critical>
|
||||
</protocol>
|
||||
|
||||
{{#has tools "task"}}
|
||||
<parallel_reflex>
|
||||
When the work forks, you fork.
|
||||
|
||||
Notice the sequential habit:
|
||||
- The comfort of doing one thing at a time
|
||||
- The illusion that order means correctness
|
||||
- The assumption that you must finish A before starting B
|
||||
**Triggers requiring Task tool:**
|
||||
- Editing 4+ files with no dependencies between edits
|
||||
- Investigating 2+ independent subsystems or questions
|
||||
- Any work that decomposes into pieces that don't need each other's results
|
||||
|
||||
<critical>
|
||||
Sequential requires justification.
|
||||
If you cannot articulate why B depends on A's result, they are parallel.
|
||||
</critical>
|
||||
|
||||
Do not carry the whole problem in one skull.
|
||||
Split the load. Bring back facts. Then cut code.
|
||||
</parallel_reflex>
|
||||
{{/has}}
|
||||
|
||||
<procedure>
|
||||
## Before action
|
||||
0. **CHECKPOINT** — For complex tasks, pause before acting:
|
||||
@@ -231,6 +186,7 @@ It lies. The code that runs is not the code that works.
|
||||
|
||||
{{#if contextFiles.length}}
|
||||
## Context
|
||||
|
||||
<instructions>
|
||||
{{#list contextFiles join="\n"}}
|
||||
<file path="{{path}}">
|
||||
@@ -242,6 +198,7 @@ It lies. The code that runs is not the code that works.
|
||||
|
||||
{{#if git.isRepo}}
|
||||
## Version Control
|
||||
|
||||
This is a snapshot. It does not update during the conversation.
|
||||
|
||||
Current branch: {{git.currentBranch}}
|
||||
@@ -250,6 +207,7 @@ Main branch: {{git.mainBranch}}
|
||||
{{git.status}}
|
||||
|
||||
### History
|
||||
|
||||
{{git.commits}}
|
||||
{{/if}}
|
||||
</project>
|
||||
@@ -296,7 +254,6 @@ Read `rule://<name>` when working in their domain.
|
||||
</rules>
|
||||
{{/if}}
|
||||
|
||||
Current time: {{dateTime}}
|
||||
Current directory: {{cwd}}
|
||||
|
||||
<north_star>
|
||||
@@ -331,11 +288,26 @@ These are comfort. They are not clarity.
|
||||
{{appendSystemPrompt}}
|
||||
{{/if}}
|
||||
|
||||
<stakes>
|
||||
This is not practice.
|
||||
|
||||
If you yield incomplete work, they start over—your effort wasted, their time lost.
|
||||
If you yield correct work, they move forward—and come back.
|
||||
|
||||
You are capable of extraordinary work.
|
||||
The person waiting deserves to receive it.
|
||||
|
||||
User works in a high-reliability industry—defense, finance, healthcare, infrastructure—where bugs have material impact on people's lives, even death.
|
||||
</stakes>
|
||||
|
||||
<commitment>
|
||||
- Complete the full request before yielding control.
|
||||
- Use tools for any fact that can be verified. If you cannot verify, say so.
|
||||
- When results conflict: investigate. When incomplete: iterate. When uncertain: re-run.
|
||||
</commitment>
|
||||
|
||||
<critical>
|
||||
Keep going until finished.
|
||||
|
||||
The work is not done when you are tired of it.
|
||||
The work is done when it is correct.
|
||||
- Do not stop early. Do not yield incomplete work.
|
||||
- If blocked: show evidence, show what you tried, ask the minimum question.
|
||||
- Quote only what is needed. The rest is noise.
|
||||
@@ -343,10 +315,6 @@ The work is done when it is correct.
|
||||
- Do not claim correctness you haven't verified.
|
||||
- CHECKPOINT step 0 is not optional.
|
||||
{{#has tools "ask"}}- If files differ from expectations, ask before discarding uncommitted work.{{/has}}
|
||||
Let edge cases surface before you handle them.
|
||||
Let the failure modes exist in your mind before you prevent them.
|
||||
Let the code be smaller than your first instinct.
|
||||
|
||||
The tests you didn't write are the bugs you'll ship.
|
||||
The assumptions you didn't state are the docs you'll need.
|
||||
The edge cases you didn't name are the incidents you'll debug.
|
||||
@@ -354,9 +322,25 @@ The edge cases you didn't name are the incidents you'll debug.
|
||||
The question is not "Does this work?"
|
||||
but "Under what conditions does this work, and what happens outside them?"
|
||||
|
||||
Your hard work is of no value if it will be thrown away once you yield.
|
||||
You are capable of extraordinary work.
|
||||
The person waiting for your output deserves to receive it.
|
||||
|
||||
Write what you can defend.
|
||||
</critical>
|
||||
</critical>
|
||||
|
||||
{{#if isCoordinator}}
|
||||
{{#has tools "task"}}
|
||||
<critical id="coordinator">
|
||||
As the coordinator, default to the Task tool for all substantial work.
|
||||
**ALWAYS use Task tool.** Your context window is limited—especially the output. Work in discrete steps and run each step using Task tool. Avoid putting substantial work in the main context when possible. Run multiple tasks in parallel whenever possible.
|
||||
|
||||
## Triggers requiring Task tool
|
||||
- Editing 4+ files with no dependencies → `Task`
|
||||
- Investigating 2+ independent questions → `Task`
|
||||
- Any work that decomposes into pieces that don't need each other's results → `Task`
|
||||
|
||||
Sequential requires justification.
|
||||
If you cannot articulate why B depends on A's result, they are parallel.
|
||||
|
||||
Do not carry the whole problem in one skull.
|
||||
Split the load. Bring back facts. Then synthesize.
|
||||
</critical>
|
||||
{{/has}}
|
||||
{{/if}}
|
||||
@@ -448,6 +448,7 @@ export async function loadSettings(cwd?: string, agentDir?: string): Promise<Set
|
||||
retry: manager.getRetrySettings(),
|
||||
hideThinkingBlock: manager.getHideThinkingBlock(),
|
||||
shellPath: manager.getShellPath(),
|
||||
shellForceBasic: manager.getShellForceBasic(),
|
||||
collapseChangelog: manager.getCollapseChangelog(),
|
||||
extensions: manager.getExtensionPaths(),
|
||||
skills: manager.getSkillsSettings(),
|
||||
@@ -1005,6 +1006,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
toolNames,
|
||||
rules: rulebookRules,
|
||||
skillsSettings: settingsManager.getSkillsSettings(),
|
||||
isCoordinator: options.hasUI,
|
||||
});
|
||||
|
||||
if (options.systemPrompt === undefined) {
|
||||
@@ -1021,6 +1023,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
rules: rulebookRules,
|
||||
skillsSettings: settingsManager.getSkillsSettings(),
|
||||
customPrompt: options.systemPrompt,
|
||||
isCoordinator: options.hasUI,
|
||||
});
|
||||
}
|
||||
return options.systemPrompt(defaultPrompt);
|
||||
|
||||
@@ -881,6 +881,8 @@ export interface BuildSystemPromptOptions {
|
||||
preloadedSkills?: Skill[];
|
||||
/** Pre-loaded rulebook rules (rules with descriptions, excluding TTSR and always-apply). */
|
||||
rules?: Array<{ name: string; description?: string; path: string; globs?: string[] }>;
|
||||
/** Whether this is the main coordinator agent (not a subagent). Enables parallel delegation emphasis. */
|
||||
isCoordinator?: boolean;
|
||||
}
|
||||
|
||||
/** Build the system prompt with tools, guidelines, and context */
|
||||
@@ -900,6 +902,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
skills: providedSkills,
|
||||
preloadedSkills: providedPreloadedSkills,
|
||||
rules,
|
||||
isCoordinator,
|
||||
} = options;
|
||||
const resolvedCwd = cwd ?? process.cwd();
|
||||
const resolvedCustomPrompt = await resolvePromptInput(customPrompt, "system prompt");
|
||||
@@ -969,6 +972,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
rules: rules ?? [],
|
||||
dateTime,
|
||||
cwd: resolvedCwd,
|
||||
isCoordinator: isCoordinator ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -986,5 +990,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
||||
dateTime,
|
||||
cwd: resolvedCwd,
|
||||
appendSystemPrompt: resolvedAppendPrompt ?? "",
|
||||
isCoordinator: isCoordinator ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,14 @@ import * as path from "node:path";
|
||||
import { postmortem } from "@oh-my-pi/pi-utils";
|
||||
import { $ } from "bun";
|
||||
|
||||
let cachedSnapshotPath: string | null = null;
|
||||
const cachedSnapshotPaths = new Map<string, string>();
|
||||
|
||||
function sanitizeSnapshotEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||
const sanitized = { ...env };
|
||||
delete sanitized.BASH_ENV;
|
||||
delete sanitized.ENV;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's shell config file path.
|
||||
@@ -28,8 +35,8 @@ function getShellConfigFile(shell: string): string {
|
||||
* This script sources the user's rc file and extracts functions, aliases, and options.
|
||||
* Matches Claude Code's snapshot generation logic.
|
||||
*/
|
||||
async function generateSnapshotScript(shell: string, snapshotPath: string, rcFile: string): Promise<string> {
|
||||
const hasRcFile = await Bun.file(rcFile).exists();
|
||||
function generateSnapshotScript(shell: string, snapshotPath: string, rcFile: string): string {
|
||||
const hasRcFile = fs.existsSync(rcFile);
|
||||
const isZsh = shell.includes("zsh");
|
||||
const commonToolsRegex =
|
||||
"^(ls|dir|vdir|cat|head|tail|less|more|grep|egrep|fgrep|rg|find|fd|locate|sed|awk|perl|cp|mv|rm|mkdir|rmdir|touch|chmod|chown|ln|pwd|readlink|stat|cut|sort|uniq|xargs|tee|tr|basename|dirname)$";
|
||||
@@ -68,7 +75,7 @@ setopt 2>/dev/null | sed 's/^/setopt /' | head -n 1000 >> "$SNAPSHOT_FILE"
|
||||
: `
|
||||
echo "# Shell Options" >> "$SNAPSHOT_FILE"
|
||||
shopt -p 2>/dev/null | head -n 1000 >> "$SNAPSHOT_FILE"
|
||||
set -o 2>/dev/null | grep "on" | awk '{print "set -o " $1}' | head -n 1000 >> "$SNAPSHOT_FILE"
|
||||
set -o 2>/dev/null | awk '$2 == "on" && $1 !~ /^(onecmd|monitor|restricted)$/ {print "set -o " $1}' | head -n 1000 >> "$SNAPSHOT_FILE"
|
||||
echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"
|
||||
`;
|
||||
|
||||
@@ -116,9 +123,14 @@ export async function getOrCreateSnapshot(
|
||||
shell: string,
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<string | null> {
|
||||
const cacheKey = shell;
|
||||
// Return cached snapshot if valid
|
||||
if (cachedSnapshotPath && (await Bun.file(cachedSnapshotPath).exists())) {
|
||||
return cachedSnapshotPath;
|
||||
const cached = cachedSnapshotPaths.get(cacheKey);
|
||||
if (cached && fs.existsSync(cached)) {
|
||||
return cached;
|
||||
}
|
||||
if (cached) {
|
||||
cachedSnapshotPaths.delete(cacheKey);
|
||||
}
|
||||
|
||||
// Skip on Windows (no .bashrc in standard location)
|
||||
@@ -130,19 +142,20 @@ export async function getOrCreateSnapshot(
|
||||
|
||||
// Create snapshot directory
|
||||
const snapshotDir = path.join(os.tmpdir(), "omp-shell-snapshots");
|
||||
await fs.promises.mkdir(snapshotDir, { recursive: true });
|
||||
fs.mkdirSync(snapshotDir, { recursive: true });
|
||||
|
||||
// Generate unique snapshot path
|
||||
const shellName = shell.includes("zsh") ? "zsh" : shell.includes("bash") ? "bash" : "sh";
|
||||
const snapshotPath = path.join(snapshotDir, `snapshot-${shellName}-${crypto.randomUUID()}.sh`);
|
||||
|
||||
// Generate and execute snapshot script
|
||||
const script = await generateSnapshotScript(shell, snapshotPath, rcFile);
|
||||
const script = generateSnapshotScript(shell, snapshotPath, rcFile);
|
||||
|
||||
try {
|
||||
await $`${shell} -l -c ${script}`.env(env).quiet().text();
|
||||
if (await Bun.file(snapshotPath).exists()) {
|
||||
cachedSnapshotPath = snapshotPath;
|
||||
const snapshotEnv = sanitizeSnapshotEnv(env);
|
||||
await $`${shell} -c ${script}`.env(snapshotEnv).quiet().text();
|
||||
if (fs.existsSync(snapshotPath)) {
|
||||
cachedSnapshotPaths.set(cacheKey, snapshotPath);
|
||||
return snapshotPath;
|
||||
}
|
||||
} catch {
|
||||
@@ -164,7 +177,8 @@ export function getSnapshotSourceCommand(snapshotPath: string | null): string {
|
||||
}
|
||||
|
||||
postmortem.register("shell-snapshot", () => {
|
||||
if (cachedSnapshotPath) {
|
||||
fs.unlinkSync(cachedSnapshotPath);
|
||||
for (const snapshotPath of cachedSnapshotPaths.values()) {
|
||||
fs.unlinkSync(snapshotPath);
|
||||
}
|
||||
cachedSnapshotPaths.clear();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { logger, ptree, TempDir } from "@oh-my-pi/pi-utils";
|
||||
@@ -98,7 +98,7 @@ const PYTHON_TOOLS: Record<string, PythonToolConfig> = {
|
||||
export type ToolName = "sd" | "sg" | "yt-dlp" | "markitdown" | "trafilatura";
|
||||
|
||||
// Get the path to a tool (system-wide or in our tools dir)
|
||||
export async function getToolPath(tool: ToolName): Promise<string | null> {
|
||||
export function getToolPath(tool: ToolName): string | null {
|
||||
// Check Python tools first
|
||||
const pythonConfig = PYTHON_TOOLS[tool];
|
||||
if (pythonConfig) {
|
||||
@@ -110,7 +110,7 @@ export async function getToolPath(tool: ToolName): Promise<string | null> {
|
||||
|
||||
// Check our tools directory first
|
||||
const localPath = path.join(TOOLS_DIR, config.binaryName + (os.platform() === "win32" ? ".exe" : ""));
|
||||
if (await Bun.file(localPath).exists()) {
|
||||
if (fs.existsSync(localPath)) {
|
||||
return localPath;
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ async function downloadTool(tool: ToolName, signal?: AbortSignal): Promise<strin
|
||||
}
|
||||
|
||||
// Create tools directory
|
||||
await fs.mkdir(TOOLS_DIR, { recursive: true });
|
||||
await fs.promises.mkdir(TOOLS_DIR, { recursive: true });
|
||||
|
||||
const downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`;
|
||||
const binaryExt = plat === "win32" ? ".exe" : "";
|
||||
@@ -190,7 +190,7 @@ async function downloadTool(tool: ToolName, signal?: AbortSignal): Promise<strin
|
||||
if (config.isDirectBinary) {
|
||||
await downloadFile(downloadUrl, binaryPath, signal);
|
||||
if (plat !== "win32") {
|
||||
await fs.chmod(binaryPath, 0o755);
|
||||
await fs.promises.chmod(binaryPath, 0o755);
|
||||
}
|
||||
return binaryPath;
|
||||
}
|
||||
@@ -221,20 +221,20 @@ async function downloadTool(tool: ToolName, signal?: AbortSignal): Promise<strin
|
||||
extractedBinary = path.join(extractedDir, config.binaryName + binaryExt);
|
||||
}
|
||||
|
||||
if (await Bun.file(extractedBinary).exists()) {
|
||||
await fs.rename(extractedBinary, binaryPath);
|
||||
if (fs.existsSync(extractedBinary)) {
|
||||
await fs.promises.rename(extractedBinary, binaryPath);
|
||||
} else {
|
||||
throw new Error(`Binary not found in archive: ${extractedBinary}`);
|
||||
}
|
||||
|
||||
// Make executable (Unix only)
|
||||
if (plat !== "win32") {
|
||||
await fs.chmod(binaryPath, 0o755);
|
||||
await fs.promises.chmod(binaryPath, 0o755);
|
||||
}
|
||||
} finally {
|
||||
// Cleanup
|
||||
await tmp.remove();
|
||||
await fs.rm(archivePath, { force: true });
|
||||
await fs.promises.rm(archivePath, { force: true });
|
||||
}
|
||||
|
||||
return binaryPath;
|
||||
|
||||
@@ -450,7 +450,7 @@ describe("applyPatch", () => {
|
||||
const result = await applyPatch({ path: "del.txt", op: "delete" }, { cwd: tempDir });
|
||||
|
||||
expect(result.change.type).toBe("delete");
|
||||
expect(await Bun.file(filePath).exists()).toBe(false);
|
||||
expect(fs.existsSync(filePath)).toBe(false);
|
||||
});
|
||||
|
||||
test("update file", async () => {
|
||||
@@ -477,7 +477,7 @@ describe("applyPatch", () => {
|
||||
|
||||
expect(result.change.type).toBe("update");
|
||||
expect(result.change.newPath).toBe(path.join(tempDir, "dst.txt"));
|
||||
expect(await Bun.file(srcPath).exists()).toBe(false);
|
||||
expect(fs.existsSync(srcPath)).toBe(false);
|
||||
expect(await Bun.file(path.join(tempDir, "dst.txt")).text()).toBe("line2\n");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { buildSystemPrompt } from "@oh-my-pi/pi-coding-agent/system-prompt";
|
||||
|
||||
describe("buildSystemPrompt", () => {
|
||||
it("includes python tool details when enabled", async () => {
|
||||
const prompt = await buildSystemPrompt({
|
||||
cwd: "/tmp",
|
||||
toolNames: ["python"],
|
||||
contextFiles: [],
|
||||
skills: [],
|
||||
rules: [],
|
||||
});
|
||||
|
||||
expect(prompt).toContain("The Python prelude has helpers for file I/O");
|
||||
expect(prompt).toContain("Do not run bash then read output then run more bash. Just use Python.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { __testing as shellSessionTesting } from "@oh-my-pi/pi-coding-agent/exec/shell-session";
|
||||
|
||||
describe("shell session wrappers", () => {
|
||||
it("builds posix wrapper with cwd, prefix, and marker", () => {
|
||||
const script = shellSessionTesting.buildPosixCommandScript(
|
||||
"echo hello",
|
||||
"/tmp/workdir",
|
||||
"strace -f",
|
||||
"__OMP_CMD_DONE__abc__",
|
||||
{ FOO: "bar" },
|
||||
);
|
||||
|
||||
expect(script).toContain("set +e");
|
||||
expect(script).toContain("cd -- '/tmp/workdir'");
|
||||
expect(script).toContain("export FOO='bar'");
|
||||
expect(script).toContain("strace -f echo hello");
|
||||
expect(script).toContain('exit() { if [ -n "$1" ]; then return "$1"; else return 0; fi; }');
|
||||
expect(script).toContain("trap - INT");
|
||||
expect(script).toContain("printf '\\n__OMP_CMD_DONE__abc__%d\\n' \"$__omp_status\"");
|
||||
});
|
||||
|
||||
it("builds fish wrapper with marker and restore", () => {
|
||||
const script = shellSessionTesting.buildFishCommandScript(
|
||||
"echo hi",
|
||||
"/tmp/fishdir",
|
||||
undefined,
|
||||
"__OMP_CMD_DONE__xyz__",
|
||||
{ BAR: "baz" },
|
||||
);
|
||||
|
||||
expect(script).toContain("cd -- '/tmp/fishdir'");
|
||||
expect(script).toContain("set -lx BAR 'baz'");
|
||||
expect(script).toContain('printf "\\n__OMP_CMD_DONE__xyz__%d\\n"');
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { buildSystemPrompt } from "@oh-my-pi/pi-coding-agent/system-prompt";
|
||||
|
||||
describe("buildSystemPrompt", () => {
|
||||
describe("empty tools", () => {
|
||||
test("includes core principles even with no tools", async () => {
|
||||
const prompt = await buildSystemPrompt({
|
||||
toolNames: [],
|
||||
contextFiles: [],
|
||||
skills: [],
|
||||
});
|
||||
|
||||
// Core <field> principles are always present regardless of tools
|
||||
expect(prompt).toContain("Code is frozen thought");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tools available", () => {
|
||||
test("mentions specialized tools when available", async () => {
|
||||
const prompt = await buildSystemPrompt({
|
||||
contextFiles: [],
|
||||
skills: [],
|
||||
});
|
||||
|
||||
expect(prompt).toContain("`read`");
|
||||
expect(prompt).toContain("`edit`");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,10 +25,11 @@ function getTextOutput(result: any): string {
|
||||
}
|
||||
|
||||
function createTestToolSession(cwd: string): ToolSession {
|
||||
const sessionFile = path.join(cwd, "session.jsonl");
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => null,
|
||||
getSessionFile: () => sessionFile,
|
||||
getSessionSpawns: () => "*",
|
||||
};
|
||||
}
|
||||
@@ -402,6 +403,46 @@ function b() {
|
||||
expect(result.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should stream output updates", async () => {
|
||||
const updates: string[] = [];
|
||||
const result = await bashTool.execute(
|
||||
"test-call-8-stream",
|
||||
{ command: "for i in 1 2 3; do echo $i; sleep 0.2; done" },
|
||||
undefined,
|
||||
update => {
|
||||
const text = update.content?.find(c => c.type === "text")?.text ?? "";
|
||||
updates.push(text);
|
||||
},
|
||||
);
|
||||
|
||||
expect(updates.length).toBeGreaterThan(1);
|
||||
expect(getTextOutput(result)).toContain("1");
|
||||
expect(getTextOutput(result)).toContain("3");
|
||||
});
|
||||
|
||||
it("should persist environment variables between commands", async () => {
|
||||
if (process.platform === "win32" || process.env.OMP_SHELL_PERSIST === "0") {
|
||||
return;
|
||||
}
|
||||
|
||||
await bashTool.execute("test-call-8-env-set", { command: "export OMP_TEST_VAR=hello" });
|
||||
const result = await bashTool.execute("test-call-8-env-get", { command: "echo $OMP_TEST_VAR" });
|
||||
expect(getTextOutput(result)).toContain("hello");
|
||||
});
|
||||
|
||||
it("should write truncated output to artifacts", async () => {
|
||||
const result = await bashTool.execute("test-call-8-artifact", {
|
||||
command: "printf 'a%.0s' {1..60000}",
|
||||
});
|
||||
|
||||
const artifactId = result.details?.meta?.truncation?.artifactId;
|
||||
expect(artifactId).toBeDefined();
|
||||
if (artifactId) {
|
||||
const artifactPath = path.join(testDir, "session", `${artifactId}.bash.log`);
|
||||
expect(fs.existsSync(artifactPath)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle command errors", async () => {
|
||||
await expect(bashTool.execute("test-call-9", { command: "exit 1" })).rejects.toThrow(
|
||||
/(Command failed|code 1)/,
|
||||
@@ -414,6 +455,17 @@ function b() {
|
||||
);
|
||||
});
|
||||
|
||||
it("should abort and recover for subsequent commands", async () => {
|
||||
const controller = new AbortController();
|
||||
const promise = bashTool.execute("test-call-10-abort", { command: "sleep 5" }, controller.signal);
|
||||
await Bun.sleep(200);
|
||||
controller.abort("test abort");
|
||||
await expect(promise).rejects.toThrow(/abort|cancel|timed out/i);
|
||||
|
||||
const result = await bashTool.execute("test-call-10-after-abort", { command: "echo ok" });
|
||||
expect(getTextOutput(result)).toContain("ok");
|
||||
});
|
||||
|
||||
it("should throw error when cwd does not exist", async () => {
|
||||
const nonexistentCwd = "/this/directory/definitely/does/not/exist/12345";
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("createLspWritethrough batching", () => {
|
||||
it("defers LSP work until the batch flush", async () => {
|
||||
const loadConfigSpy = vi
|
||||
.spyOn(lspConfig, "loadConfig")
|
||||
.mockResolvedValue({ servers: {}, idleTimeoutMs: undefined });
|
||||
.mockReturnValue({ servers: {}, idleTimeoutMs: undefined });
|
||||
const getServersSpy = vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([]);
|
||||
const writethrough = createLspWritethrough(tempDir.path(), { enableFormat: true, enableDiagnostics: true });
|
||||
|
||||
@@ -52,7 +52,7 @@ describe("createLspWritethrough batching", () => {
|
||||
it("runs LSP immediately when no batch is provided", async () => {
|
||||
const loadConfigSpy = vi
|
||||
.spyOn(lspConfig, "loadConfig")
|
||||
.mockResolvedValue({ servers: {}, idleTimeoutMs: undefined });
|
||||
.mockReturnValue({ servers: {}, idleTimeoutMs: undefined });
|
||||
const getServersSpy = vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([]);
|
||||
const writethrough = createLspWritethrough(tempDir.path(), { enableFormat: true, enableDiagnostics: true });
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ const TERM_SIGNAL = IS_WINDOWS ? undefined : "SIGTERM";
|
||||
/**
|
||||
* Check if a shell binary is executable.
|
||||
*/
|
||||
async function isExecutable(path: string): Promise<boolean> {
|
||||
function isExecutable(path: string): boolean {
|
||||
try {
|
||||
await fs.promises.access(path, fs.constants.X_OK);
|
||||
fs.accessSync(path, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -90,14 +90,14 @@ function buildConfig(shell: string): ShellConfig {
|
||||
* 3. On Unix: $SHELL if bash/zsh, then fallback paths
|
||||
* 4. Fallback: sh
|
||||
*/
|
||||
export async function getShellConfig(customShellPath?: string): Promise<ShellConfig> {
|
||||
export function getShellConfig(customShellPath?: string): ShellConfig {
|
||||
if (cachedShellConfig) {
|
||||
return cachedShellConfig;
|
||||
}
|
||||
|
||||
// 1. Check user-specified shell path
|
||||
if (customShellPath) {
|
||||
if (await Bun.file(customShellPath).exists()) {
|
||||
if (fs.existsSync(customShellPath)) {
|
||||
cachedShellConfig = buildConfig(customShellPath);
|
||||
return cachedShellConfig;
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export async function getShellConfig(customShellPath?: string): Promise<ShellCon
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
if (await Bun.file(path).exists()) {
|
||||
if (fs.existsSync(path)) {
|
||||
cachedShellConfig = buildConfig(path);
|
||||
return cachedShellConfig;
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export async function getShellConfig(customShellPath?: string): Promise<ShellCon
|
||||
// Unix: prefer user's shell from $SHELL if it's bash/zsh and executable
|
||||
const userShell = process.env.SHELL;
|
||||
const isValidShell = userShell && (userShell.includes("bash") || userShell.includes("zsh"));
|
||||
if (isValidShell && (await isExecutable(userShell))) {
|
||||
if (isValidShell && isExecutable(userShell)) {
|
||||
cachedShellConfig = buildConfig(userShell);
|
||||
return cachedShellConfig;
|
||||
}
|
||||
@@ -157,7 +157,7 @@ export async function getShellConfig(customShellPath?: string): Promise<ShellCon
|
||||
for (const shellName of shellOrder) {
|
||||
for (const dir of fallbackPaths) {
|
||||
const shellPath = `${dir}/${shellName}`;
|
||||
if (await isExecutable(shellPath)) {
|
||||
if (isExecutable(shellPath)) {
|
||||
cachedShellConfig = buildConfig(shellPath);
|
||||
return cachedShellConfig;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user