mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-18 14:02:51 +00:00
Add bash mode for executing shell commands
- Add ! prefix in TUI editor to execute shell commands directly
- Output streams in real-time and is added to LLM context
- Supports multiline commands, cancellation (Escape), truncation
- Preview mode shows last 20 lines, Ctrl+O expands full output
- Commands persist in session history as bashExecution messages
- Add bash command to RPC mode via {type:'bash',command:'...'}
- Add RPC tests for bash command execution and context inclusion
- Update docs: rpc.md, session.md, README.md, CHANGELOG.md
Closes #112
Co-authored-by: Markus Ylisiurunen <markus.ylisiurunen@gmail.com>
This commit is contained in:
parent
1608da8770
commit
bd0d0676d4
13 changed files with 917 additions and 126 deletions
|
|
@ -2,9 +2,12 @@ import { Agent, type Attachment, ProviderTransport, type ThinkingLevel } from "@
|
|||
import type { Api, AssistantMessage, KnownProvider, Model } from "@mariozechner/pi-ai";
|
||||
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
|
||||
import chalk from "chalk";
|
||||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { spawn } from "child_process";
|
||||
import { randomBytes } from "crypto";
|
||||
import { createWriteStream, existsSync, readFileSync, statSync } from "fs";
|
||||
import { homedir, tmpdir } from "os";
|
||||
import { extname, join, resolve } from "path";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js";
|
||||
import { calculateContextTokens, compact, shouldCompact } from "./compaction.js";
|
||||
import {
|
||||
|
|
@ -17,12 +20,15 @@ import {
|
|||
VERSION,
|
||||
} from "./config.js";
|
||||
import { exportFromFile } from "./export-html.js";
|
||||
import { type BashExecutionMessage, messageTransformer } from "./messages.js";
|
||||
import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js";
|
||||
import { loadSessionFromEntries, SessionManager } from "./session-manager.js";
|
||||
import { SettingsManager } from "./settings-manager.js";
|
||||
import { getShellConfig } from "./shell.js";
|
||||
import { expandSlashCommand, loadSlashCommands } from "./slash-commands.js";
|
||||
import { initTheme } from "./theme/theme.js";
|
||||
import { allTools, codingTools, type ToolName } from "./tools/index.js";
|
||||
import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.js";
|
||||
import { ensureTool } from "./tools-manager.js";
|
||||
import { SessionSelectorComponent } from "./tui/session-selector.js";
|
||||
import { TuiRenderer } from "./tui/tui-renderer.js";
|
||||
|
|
@ -856,6 +862,87 @@ async function runSingleShotMode(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a bash command for RPC mode.
|
||||
* Similar to tui-renderer's executeBashCommand but without streaming callbacks.
|
||||
*/
|
||||
async function executeRpcBashCommand(command: string): Promise<{
|
||||
output: string;
|
||||
exitCode: number | null;
|
||||
truncationResult?: ReturnType<typeof truncateTail>;
|
||||
fullOutputPath?: string;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { shell, args } = getShellConfig();
|
||||
const child = spawn(shell, [...args, command], {
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let chunksBytes = 0;
|
||||
const maxChunksBytes = DEFAULT_MAX_BYTES * 2;
|
||||
|
||||
let tempFilePath: string | undefined;
|
||||
let tempFileStream: ReturnType<typeof createWriteStream> | undefined;
|
||||
let totalBytes = 0;
|
||||
|
||||
const handleData = (data: Buffer) => {
|
||||
totalBytes += data.length;
|
||||
|
||||
// Start writing to temp file if exceeds threshold
|
||||
if (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {
|
||||
const id = randomBytes(8).toString("hex");
|
||||
tempFilePath = join(tmpdir(), `pi-bash-${id}.log`);
|
||||
tempFileStream = createWriteStream(tempFilePath);
|
||||
for (const chunk of chunks) {
|
||||
tempFileStream.write(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
if (tempFileStream) {
|
||||
tempFileStream.write(data);
|
||||
}
|
||||
|
||||
// Keep rolling buffer
|
||||
chunks.push(data);
|
||||
chunksBytes += data.length;
|
||||
while (chunksBytes > maxChunksBytes && chunks.length > 1) {
|
||||
const removed = chunks.shift()!;
|
||||
chunksBytes -= removed.length;
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.on("data", handleData);
|
||||
child.stderr?.on("data", handleData);
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (tempFileStream) {
|
||||
tempFileStream.end();
|
||||
}
|
||||
|
||||
// Combine buffered chunks
|
||||
const fullBuffer = Buffer.concat(chunks);
|
||||
const fullOutput = stripAnsi(fullBuffer.toString("utf-8")).replace(/\r/g, "");
|
||||
const truncationResult = truncateTail(fullOutput);
|
||||
|
||||
resolve({
|
||||
output: fullOutput,
|
||||
exitCode: code,
|
||||
truncationResult: truncationResult.truncated ? truncationResult : undefined,
|
||||
fullOutputPath: tempFilePath,
|
||||
});
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
if (tempFileStream) {
|
||||
tempFileStream.end();
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runRpcMode(
|
||||
agent: Agent,
|
||||
sessionManager: SessionManager,
|
||||
|
|
@ -986,6 +1073,37 @@ async function runRpcMode(
|
|||
} catch (error: any) {
|
||||
console.log(JSON.stringify({ type: "error", error: `Compaction failed: ${error.message}` }));
|
||||
}
|
||||
} else if (input.type === "bash" && input.command) {
|
||||
// Execute bash command and add to context
|
||||
try {
|
||||
const result = await executeRpcBashCommand(input.command);
|
||||
|
||||
// Create bash execution message
|
||||
const bashMessage: BashExecutionMessage = {
|
||||
role: "bashExecution",
|
||||
command: input.command,
|
||||
output: result.truncationResult?.content || result.output,
|
||||
exitCode: result.exitCode,
|
||||
cancelled: false,
|
||||
truncated: result.truncationResult?.truncated || false,
|
||||
fullOutputPath: result.fullOutputPath,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Add to agent state and save to session
|
||||
agent.appendMessage(bashMessage);
|
||||
sessionManager.saveMessage(bashMessage);
|
||||
|
||||
// Initialize session if needed (same logic as message_end handler)
|
||||
if (sessionManager.shouldInitializeSession(agent.state.messages)) {
|
||||
sessionManager.startSession(agent.state);
|
||||
}
|
||||
|
||||
// Emit bash_end event with the message
|
||||
console.log(JSON.stringify({ type: "bash_end", message: bashMessage }));
|
||||
} catch (error: any) {
|
||||
console.log(JSON.stringify({ type: "error", error: `Bash command failed: ${error.message}` }));
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Output error as JSON
|
||||
|
|
@ -1273,6 +1391,7 @@ export async function main(args: string[]) {
|
|||
thinkingLevel: initialThinking,
|
||||
tools: selectedTools,
|
||||
},
|
||||
messageTransformer,
|
||||
queueMode: settingsManager.getQueueMode(),
|
||||
transport: new ProviderTransport({
|
||||
// Dynamic API key lookup based on current model's provider
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue