Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
314 changes: 225 additions & 89 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,20 +472,21 @@ async function main(): Promise<void> {

agent.subscribe((event) => handleEvent(event, hooksConfig, agentDir, sessionId, auditLogger));

console.log(bold(`${manifest.name} v${manifest.version}`));
console.log(dim(`Model: ${loaded.model.provider}:${loaded.model.id}`));
let headerLines = 0;
console.log(bold(`${manifest.name} v${manifest.version}`)); headerLines++;
console.log(dim(`Model: ${loaded.model.provider}:${loaded.model.id}`)); headerLines++;
const allToolNames = tools.map((t) => t.name);
console.log(dim(`Tools: ${allToolNames.join(", ")}`));
console.log(dim(`Tools: ${allToolNames.join(", ")}`)); headerLines++;
if (skills.length > 0) {
console.log(dim(`Skills: ${skills.map((s) => s.name).join(", ")}`));
console.log(dim(`Skills: ${skills.map((s) => s.name).join(", ")}`)); headerLines++;
}
if (loaded.workflows.length > 0) {
console.log(dim(`Workflows: ${loaded.workflows.map((w) => w.name).join(", ")}`));
console.log(dim(`Workflows: ${loaded.workflows.map((w) => w.name).join(", ")}`)); headerLines++;
}
if (loaded.subAgents.length > 0) {
console.log(dim(`Agents: ${loaded.subAgents.map((a) => a.name).join(", ")}`));
console.log(dim(`Agents: ${loaded.subAgents.map((a) => a.name).join(", ")}`)); headerLines++;
}
console.log(dim('Type /skills to list skills, /memory to view memory, /quit to exit\n'));
console.log(dim('Type /skills to list skills, /memory to view memory, /quit to exit\n')); headerLines += 2;

// Single-shot mode
if (prompt) {
Expand Down Expand Up @@ -515,113 +516,248 @@ async function main(): Promise<void> {
return;
}

// REPL mode
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
// Sandbox cleanup helper
const stopSandbox = async () => {
if (sandboxCtx) {
console.log(dim("Stopping sandbox..."));
await sandboxCtx.gitMachine.stop();
}
};

const ask = (): void => {
rl.question(green("→ "), async (input) => {
const trimmed = input.trim();
// ── Unified REPL with fixed input line ───────────────────────
// Always in raw mode. Scroll region keeps streaming output in
// the top area; input line is always visible at the bottom.
// No readline — we manage everything ourselves.

if (!trimmed) {
ask();
return;
let inputBuffer = "";
let isRunning = false; // true while agent.prompt() is in progress
let queueText = ""; // pending steer message shown on the queue line

const rows = () => process.stdout.rows || 24;
const cols = () => process.stdout.columns || 80;

// Layout (bottom 3 rows are reserved):
// rows 1..(r-3) — scroll region (streaming output)
// row r-2 — queue line (pending steer message)
// row r-1 — separator
// row r — input line

const drawQueueLine = () => {
const r = rows();
if (queueText) {
process.stdout.write(`\x1b7\x1b[${r - 2};1H\x1b[2K\x1b[33m⤷ ${queueText}\x1b[0m\x1b8`);
} else {
process.stdout.write(`\x1b7\x1b[${r - 2};1H\x1b[2K\x1b8`);
}
};

const drawSeparator = () => {
const r = rows();
process.stdout.write(`\x1b7\x1b[${r - 1};1H\x1b[2K\x1b[2m${"─".repeat(cols())}\x1b[0m\x1b8`);
};

const drawInputLine = () => {
const r = rows();
const prompt = isRunning ? `\x1b[2m⤷\x1b[0m ` : `\x1b[32m→\x1b[0m `;
// Fake block cursor (inverse video space) shows typing position
const cursor = `\x1b[7m \x1b[0m`;
// Save/restore keeps the real (hidden) cursor in the scroll region
process.stdout.write(`\x1b7\x1b[${r};1H\x1b[2K${prompt}${inputBuffer}${cursor}\x1b8`);
};

const initUI = (cursorRow: number) => {
if (!process.stdout.isTTY) return;
const r = rows();
// Hide real cursor — we use a fake cursor on the input line
process.stdout.write(`\x1b[?25l`);
// Set scroll region: rows 1 to (r-3), leaving 3 rows for queue + separator + input
process.stdout.write(`\x1b[1;${r - 3}r`);
drawQueueLine();
drawSeparator();
drawInputLine();
// Reposition cursor after header so new output doesn't overwrite it
// (setting scroll region resets cursor to row 1)
process.stdout.write(`\x1b[${cursorRow};1H`);
};

const cleanupUI = () => {
if (!process.stdout.isTTY) return;
// Show real cursor again
process.stdout.write(`\x1b[?25h`);
// Reset scroll region, clear bottom lines
const r = rows();
process.stdout.write(`\x1b[${r - 2};1H\x1b[2K`);
process.stdout.write(`\x1b[${r - 1};1H\x1b[2K`);
process.stdout.write(`\x1b[${r};1H\x1b[2K`);
process.stdout.write(`\x1b[r`);
};

// Handle a command (when agent is idle)
const handleCommand = async (text: string) => {
if (text === "/quit" || text === "/exit") {
cleanupUI();
if (process.stdin.isTTY) process.stdin.setRawMode(false);
console.log("Bye!");
if (localSession) {
try { localSession.finalize(); } catch { /* best-effort */ }
}
await stopSandbox();
process.exit(0);
}

if (trimmed === "/quit" || trimmed === "/exit") {
rl.close();
if (localSession) {
console.log(dim("Finalizing session..."));
localSession.finalize();
}
await stopSandbox();
process.exit(0);
if (text === "/memory") {
try {
const mem = await readFile(join(dir, "memory/MEMORY.md"), "utf-8");
process.stdout.write(dim("--- memory ---\n"));
process.stdout.write((mem.trim() || "(empty)") + "\n");
process.stdout.write(dim("--- end ---\n"));
} catch {
process.stdout.write(dim("(no memory file)\n"));
}
drawInputLine();
return;
}

if (trimmed === "/memory") {
try {
const mem = await readFile(join(dir, "memory/MEMORY.md"), "utf-8");
console.log(dim("--- memory ---"));
console.log(mem.trim() || "(empty)");
console.log(dim("--- end ---"));
} catch {
console.log(dim("(no memory file)"));
if (text === "/skills") {
if (skills.length === 0) {
process.stdout.write(dim("No skills installed.\n"));
} else {
for (const s of skills) {
process.stdout.write(` ${bold(s.name)} — ${dim(s.description)}\n`);
}
ask();
return;
}
drawInputLine();
return;
}

if (trimmed === "/skills") {
if (skills.length === 0) {
console.log(dim("No skills installed."));
} else {
for (const s of skills) {
console.log(` ${bold(s.name)} — ${dim(s.description)}`);
}
}
ask();
// Skill expansion
let promptText = text;
if (text.startsWith("/skill:")) {
const result = await expandSkillCommand(text, skills);
if (result) {
process.stdout.write(dim(`▶ loading skill: ${result.skillName}\n`));
promptText = result.expanded;
} else {
const requested = text.match(/^\/skill:([a-z0-9-]*)/)?.[1] || "?";
process.stdout.write(red(`Unknown skill: ${requested}\n`));
drawInputLine();
return;
}
}

// Send prompt to agent
isRunning = true;
drawInputLine(); // switch prompt from → to ⤷
process.stdout.write(`\n${green(`→ ${text}`)}\n`); // blank line + echo user input

try {
await agent.prompt(promptText);
} catch (err: any) {
process.stdout.write(red(`Error: ${err.message}\n`));
auditLogger?.logError(err.message).catch(() => {});
if (hooksConfig?.hooks.on_error) {
runHooks(hooksConfig.hooks.on_error, agentDir, {
event: "on_error",
session_id: sessionId,
error: err.message,
}).catch(() => {});
}
}

isRunning = false;
queueText = "";
drawQueueLine(); // clear steer message
drawInputLine(); // switch prompt back from ⤷ to →
};

// Skill expansion: /skill:name [args]
let promptText = trimmed;
if (trimmed.startsWith("/skill:")) {
const result = await expandSkillCommand(trimmed, skills);
if (result) {
console.log(dim(`▶ loading skill: ${result.skillName}`));
promptText = result.expanded;
} else {
const requested = trimmed.match(/^\/skill:([a-z0-9-]*)/)?.[1] || "?";
console.error(red(`Unknown skill: ${requested}`));
ask();
return;
const onKeystroke = (key: Buffer) => {
const ch = key.toString("utf-8");
const code = key[0];

// Ctrl+C
if (code === 3) {
if (isRunning) {
inputBuffer = "";
drawInputLine();
agent.abort();
} else {
cleanupUI();
if (process.stdin.isTTY) process.stdin.setRawMode(false);
console.log("\nBye!");
if (localSession) {
try { localSession.finalize(); } catch { /* best-effort */ }
}
stopSandbox().finally(() => process.exit(0));
}
return;
}

try {
await agent.prompt(promptText);
} catch (err: any) {
console.error(red(`Error: ${err.message}`));
auditLogger?.logError(err.message).catch(() => {});
// Fire on_error hooks
if (hooksConfig?.hooks.on_error) {
runHooks(hooksConfig.hooks.on_error, agentDir, {
event: "on_error",
session_id: sessionId,
error: err.message,
}).catch(() => {});
}
// Enter
if (code === 13 || code === 10) {
const text = inputBuffer.trim();
inputBuffer = "";
drawInputLine();
if (!text) return;

if (isRunning) {
// Show on queue line until agent picks it up
queueText = text;
drawQueueLine();
agent.steer({
role: "user",
content: text,
timestamp: Date.now(),
});
} else {
// Send as new prompt
handleCommand(text);
}
return;
}

ask();
});
};
// Backspace
if (code === 127 || code === 8) {
inputBuffer = inputBuffer.slice(0, -1);
drawInputLine();
return;
}

// Sandbox cleanup helper
const stopSandbox = async () => {
if (sandboxCtx) {
console.log(dim("Stopping sandbox..."));
await sandboxCtx.gitMachine.stop();
// Printable characters
if (code >= 32) {
inputBuffer += ch;
drawInputLine();
}
};

// Handle Ctrl+C during streaming
rl.on("SIGINT", () => {
if (agent.state.isStreaming) {
agent.abort();
} else {
console.log("\nBye!");
rl.close();
if (localSession) {
try { localSession.finalize(); } catch { /* best-effort */ }
// Handle terminal resize — redraw all fixed lines
process.stdout.on("resize", () => {
const r = rows();
process.stdout.write(`\x1b[1;${r - 3}r`);
drawQueueLine();
drawSeparator();
drawInputLine();
});

// Echo steer messages in scroll region when agent picks them up
agent.subscribe((event) => {
if (event.type === "message_start") {
const msg = event.message as any;
if (msg?.role === "user" && queueText) {
// Agent picked up the steer — echo it in the scroll region
process.stdout.write(`\x1b[33m⤷ ${queueText}\x1b[0m\n`);
queueText = "";
drawQueueLine();
}
stopSandbox().finally(() => process.exit(0));
}
});

ask();
// Start the UI
initUI(headerLines + 1);
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.on("data", onKeystroke);
}
}

main().catch((err) => {
Expand Down