mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 12:04:35 +00:00
feat(pi-dosbox): persistent DOSBox with QBasic and agent tool
- DOSBox now starts at session_start and persists in background - /dosbox command attaches UI to running instance (Ctrl+Q detaches) - Added dosbox tool with actions: send_keys, screenshot, read_text - Bundled QuickBASIC 4.5 files, mounted at C:\QB on startup - Agent can interact with DOSBox programmatically via tool Use: pi -e ./examples/extensions/pi-dosbox Then: /dosbox to view, or let agent use the dosbox tool
This commit is contained in:
parent
fbd6b7f9ba
commit
4f343f39b9
26 changed files with 1618 additions and 373 deletions
|
|
@ -1,44 +1,183 @@
|
|||
/**
|
||||
* DOSBox extension for pi
|
||||
*
|
||||
* Features:
|
||||
* - Persistent DOSBox instance running in background
|
||||
* - QuickBASIC 4.5 mounted at C:\QB
|
||||
* - /dosbox command to view and interact with DOSBox
|
||||
* - dosbox tool for agent to send keys, read screen, take screenshots
|
||||
*
|
||||
* Usage: pi --extension ./examples/extensions/pi-dosbox
|
||||
* Command: /dosbox [bundle.jsdos]
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { DosboxComponent } from "./src/dosbox-component.js";
|
||||
import { DosboxInstance } from "./src/dosbox-instance.js";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("dosbox", {
|
||||
description: "Run DOSBox emulator",
|
||||
// Start DOSBox instance at session start
|
||||
pi.on("session_start", async () => {
|
||||
try {
|
||||
await DosboxInstance.getInstance();
|
||||
} catch (error) {
|
||||
console.error("Failed to start DOSBox:", error);
|
||||
}
|
||||
});
|
||||
|
||||
handler: async (args, ctx) => {
|
||||
// Clean up on session shutdown
|
||||
pi.on("session_shutdown", async () => {
|
||||
await DosboxInstance.destroyInstance();
|
||||
});
|
||||
|
||||
// Register /dosbox command to view DOSBox
|
||||
pi.registerCommand("dosbox", {
|
||||
description: "View and interact with DOSBox (Ctrl+Q to detach)",
|
||||
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("DOSBox requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const bundlePath = args?.trim();
|
||||
let bundleData: Uint8Array | undefined;
|
||||
if (bundlePath) {
|
||||
try {
|
||||
const resolvedPath = resolve(ctx.cwd, bundlePath);
|
||||
bundleData = await readFile(resolvedPath);
|
||||
} catch (error) {
|
||||
ctx.ui.notify(
|
||||
`Failed to load bundle: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Ensure instance is running
|
||||
const instance = DosboxInstance.getInstanceSync();
|
||||
if (!instance || !instance.isReady()) {
|
||||
ctx.ui.notify("DOSBox is not running. It should start automatically.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.ui.custom((tui, theme, _kb, done) => {
|
||||
const fallbackColor = (s: string) => theme.fg("warning", s);
|
||||
return new DosboxComponent(tui, fallbackColor, () => done(undefined), bundleData);
|
||||
return new DosboxComponent(tui, fallbackColor, () => done(undefined));
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Register dosbox tool for agent interaction
|
||||
pi.registerTool({
|
||||
name: "dosbox",
|
||||
label: "DOSBox",
|
||||
description: `Interact with DOSBox emulator running QuickBASIC 4.5.
|
||||
Actions:
|
||||
- send_keys: Send keystrokes to DOSBox. Use \\n for Enter, \\t for Tab.
|
||||
- screenshot: Get a PNG screenshot of the current DOSBox screen.
|
||||
- read_text: Read text-mode screen content (returns null in graphics mode).
|
||||
|
||||
QuickBASIC 4.5 is mounted at C:\\QB. Run "C:\\QB\\QB.EXE" to start it.`,
|
||||
parameters: Type.Object({
|
||||
action: StringEnum(["send_keys", "screenshot", "read_text"] as const, {
|
||||
description: "The action to perform",
|
||||
}),
|
||||
keys: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"For send_keys: the keys to send. Use \\n for Enter, \\t for Tab, or special:<key> for special keys (enter, backspace, tab, escape, up, down, left, right, f5)",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
|
||||
const { action, keys } = params;
|
||||
|
||||
const instance = DosboxInstance.getInstanceSync();
|
||||
if (!instance || !instance.isReady()) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: DOSBox is not running" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case "send_keys": {
|
||||
if (!keys) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: keys parameter required for send_keys action" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Handle special keys
|
||||
if (keys.startsWith("special:")) {
|
||||
const specialKey = keys.slice(8) as
|
||||
| "enter"
|
||||
| "backspace"
|
||||
| "tab"
|
||||
| "escape"
|
||||
| "up"
|
||||
| "down"
|
||||
| "left"
|
||||
| "right"
|
||||
| "f5";
|
||||
instance.sendSpecialKey(specialKey);
|
||||
return {
|
||||
content: [{ type: "text", text: `Sent special key: ${specialKey}` }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Handle escape sequences
|
||||
const processedKeys = keys.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\r/g, "\r");
|
||||
|
||||
instance.sendKeys(processedKeys);
|
||||
return {
|
||||
content: [{ type: "text", text: `Sent ${processedKeys.length} characters` }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
case "screenshot": {
|
||||
const screenshot = instance.getScreenshot();
|
||||
if (!screenshot) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: No frame available yet" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
data: screenshot.base64,
|
||||
mimeType: "image/png",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `Screenshot: ${screenshot.width}x${screenshot.height} pixels`,
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
case "read_text": {
|
||||
const text = instance.readScreenText();
|
||||
if (text === null) {
|
||||
const state = instance.getState();
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Screen is in graphics mode (${state.width}x${state.height}). Use screenshot action to see the display.`,
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: text || "(empty screen)" }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
content: [{ type: "text", text: `Error: Unknown action: ${action}` }],
|
||||
details: {},
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue