mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 05:02:07 +00:00
daemon with coding agent
This commit is contained in:
parent
a20a72cd2e
commit
3e72ca7f4b
7 changed files with 610 additions and 279 deletions
|
|
@ -441,6 +441,7 @@ pi config # Enable/disable package resources
|
|||
|------|-------------|
|
||||
| (default) | Interactive mode |
|
||||
| `-p`, `--print` | Print response and exit |
|
||||
| `daemon` | Start a long-lived, non-interactive daemon that keeps extensions running |
|
||||
| `--mode json` | Output all events as JSON lines (see [docs/json.md](docs/json.md)) |
|
||||
| `--mode rpc` | RPC mode for process integration (see [docs/rpc.md](docs/rpc.md)) |
|
||||
| `--export <in> [out]` | Export session to HTML |
|
||||
|
|
|
|||
|
|
@ -184,9 +184,10 @@ ${chalk.bold("Usage:")}
|
|||
|
||||
${chalk.bold("Commands:")}
|
||||
${APP_NAME} install <source> [-l] Install extension source and add to settings
|
||||
${APP_NAME} remove <source> [-l] Remove extension source from settings
|
||||
${APP_NAME} remove <source> [-l] Remove extension source from settings
|
||||
${APP_NAME} update [source] Update installed extensions (skips pinned sources)
|
||||
${APP_NAME} list List installed extensions from settings
|
||||
${APP_NAME} daemon Run in long-lived daemon mode (extensions stay active)
|
||||
${APP_NAME} config Open TUI to enable/disable package resources
|
||||
${APP_NAME} <command> --help Show help for install/remove/update/list
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { SettingsManager } from "./core/settings-manager.js";
|
|||
import { printTimings, time } from "./core/timings.js";
|
||||
import { allTools } from "./core/tools/index.js";
|
||||
import { runMigrations, showDeprecationWarnings } from "./migrations.js";
|
||||
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js";
|
||||
import { type DaemonModeOptions, InteractiveMode, runDaemonMode, runPrintMode, runRpcMode } from "./modes/index.js";
|
||||
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js";
|
||||
|
||||
/**
|
||||
|
|
@ -79,6 +79,19 @@ interface PackageCommandOptions {
|
|||
invalidOption?: string;
|
||||
}
|
||||
|
||||
function printDaemonHelp(): void {
|
||||
console.log(`${chalk.bold("Usage:")}
|
||||
${APP_NAME} daemon [options] [messages...]
|
||||
|
||||
Run pi as a long-lived daemon (non-interactive) with extensions enabled.
|
||||
Messages passed as positional args are sent once at startup.
|
||||
|
||||
Options:
|
||||
--list-models [search] List available models and exit
|
||||
--help, -h Show this help
|
||||
`);
|
||||
}
|
||||
|
||||
function getPackageCommandUsage(command: PackageCommand): string {
|
||||
switch (command) {
|
||||
case "install":
|
||||
|
|
@ -540,6 +553,8 @@ async function handleConfigCommand(args: string[]): Promise<boolean> {
|
|||
}
|
||||
|
||||
export async function main(args: string[]) {
|
||||
const isDaemonCommand = args[0] === "daemon";
|
||||
const parsedArgs = isDaemonCommand ? args.slice(1) : args;
|
||||
const offlineMode = args.includes("--offline") || isTruthyEnvFlag(process.env.PI_OFFLINE);
|
||||
if (offlineMode) {
|
||||
process.env.PI_OFFLINE = "1";
|
||||
|
|
@ -558,7 +573,7 @@ export async function main(args: string[]) {
|
|||
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(process.cwd());
|
||||
|
||||
// First pass: parse args to get --extension paths
|
||||
const firstPass = parseArgs(args);
|
||||
const firstPass = parseArgs(parsedArgs);
|
||||
|
||||
// Early load extensions to discover their CLI flags
|
||||
const cwd = process.cwd();
|
||||
|
|
@ -606,7 +621,7 @@ export async function main(args: string[]) {
|
|||
}
|
||||
|
||||
// Second pass: parse args with extension flags
|
||||
const parsed = parseArgs(args, extensionFlags);
|
||||
const parsed = parseArgs(parsedArgs, extensionFlags);
|
||||
|
||||
// Pass flag values to extensions via runtime
|
||||
for (const [name, value] of parsed.unknownFlags) {
|
||||
|
|
@ -619,7 +634,11 @@ export async function main(args: string[]) {
|
|||
}
|
||||
|
||||
if (parsed.help) {
|
||||
printHelp();
|
||||
if (isDaemonCommand) {
|
||||
printDaemonHelp();
|
||||
} else {
|
||||
printHelp();
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
|
@ -629,8 +648,13 @@ export async function main(args: string[]) {
|
|||
process.exit(0);
|
||||
}
|
||||
|
||||
// Read piped stdin content (if any) - skip for RPC mode which uses stdin for JSON-RPC
|
||||
if (parsed.mode !== "rpc") {
|
||||
if (isDaemonCommand && parsed.mode === "rpc") {
|
||||
console.error(chalk.red("Cannot use --mode rpc with the daemon command."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read piped stdin content (if any) - skip for daemon and RPC modes
|
||||
if (!isDaemonCommand && parsed.mode !== "rpc") {
|
||||
const stdinContent = await readPipedStdin();
|
||||
if (stdinContent !== undefined) {
|
||||
// Force print mode since interactive mode requires a TTY for keyboard input
|
||||
|
|
@ -660,7 +684,7 @@ export async function main(args: string[]) {
|
|||
}
|
||||
|
||||
const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize());
|
||||
const isInteractive = !parsed.print && parsed.mode === undefined;
|
||||
const isInteractive = !isDaemonCommand && !parsed.print && parsed.mode === undefined;
|
||||
const mode = parsed.mode || "text";
|
||||
initTheme(settingsManager.getTheme(), isInteractive);
|
||||
|
||||
|
|
@ -765,6 +789,13 @@ export async function main(args: string[]) {
|
|||
verbose: parsed.verbose,
|
||||
});
|
||||
await mode.run();
|
||||
} else if (isDaemonCommand) {
|
||||
const daemonOptions: DaemonModeOptions = {
|
||||
initialMessage,
|
||||
initialImages,
|
||||
messages: parsed.messages,
|
||||
};
|
||||
await runDaemonMode(session, daemonOptions);
|
||||
} else {
|
||||
await runPrintMode(session, {
|
||||
mode,
|
||||
|
|
|
|||
143
packages/coding-agent/src/modes/daemon-mode.ts
Normal file
143
packages/coding-agent/src/modes/daemon-mode.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* Daemon mode (always-on background execution).
|
||||
*
|
||||
* Starts agent extensions, accepts messages from extension sources
|
||||
* (webhooks, queues, Telegram/Slack gateways, etc.), and stays alive
|
||||
* until explicitly stopped.
|
||||
*/
|
||||
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { AgentSession } from "../core/agent-session.js";
|
||||
|
||||
/**
|
||||
* Options for daemon mode.
|
||||
*/
|
||||
export interface DaemonModeOptions {
|
||||
/** First message to send at startup (can include @file content expansion by caller). */
|
||||
initialMessage?: string;
|
||||
/** Images to attach to the startup message. */
|
||||
initialImages?: ImageContent[];
|
||||
/** Additional startup messages (sent after initialMessage, one by one). */
|
||||
messages?: string[];
|
||||
}
|
||||
|
||||
function createCommandContextActions(session: AgentSession) {
|
||||
return {
|
||||
waitForIdle: () => session.agent.waitForIdle(),
|
||||
newSession: async (options?: {
|
||||
parentSession?: string;
|
||||
setup?: (sessionManager: typeof session.sessionManager) => Promise<void> | void;
|
||||
}) => {
|
||||
const success = await session.newSession({ parentSession: options?.parentSession });
|
||||
if (success && options?.setup) {
|
||||
await options.setup(session.sessionManager);
|
||||
}
|
||||
return { cancelled: !success };
|
||||
},
|
||||
fork: async (entryId: string) => {
|
||||
const result = await session.fork(entryId);
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
navigateTree: async (
|
||||
targetId: string,
|
||||
options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string },
|
||||
) => {
|
||||
const result = await session.navigateTree(targetId, {
|
||||
summarize: options?.summarize,
|
||||
customInstructions: options?.customInstructions,
|
||||
replaceInstructions: options?.replaceInstructions,
|
||||
label: options?.label,
|
||||
});
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
switchSession: async (sessionPath: string) => {
|
||||
const success = await session.switchSession(sessionPath);
|
||||
return { cancelled: !success };
|
||||
},
|
||||
reload: async () => {
|
||||
await session.reload();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run in daemon mode.
|
||||
* Stays alive indefinitely unless stopped by signal or extension trigger.
|
||||
*/
|
||||
export async function runDaemonMode(session: AgentSession, options: DaemonModeOptions): Promise<never> {
|
||||
const { initialMessage, initialImages, messages = [] } = options;
|
||||
let isShuttingDown = false;
|
||||
let resolveReady: () => void = () => {};
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
resolveReady = resolve;
|
||||
});
|
||||
|
||||
const shutdown = async (reason: "signal" | "extension"): Promise<void> => {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
|
||||
console.error(`[co-mono-daemon] shutdown requested: ${reason}`);
|
||||
|
||||
const runner = session.extensionRunner;
|
||||
if (runner?.hasHandlers("session_shutdown")) {
|
||||
await runner.emit({ type: "session_shutdown" });
|
||||
}
|
||||
|
||||
session.dispose();
|
||||
resolveReady();
|
||||
};
|
||||
|
||||
const handleShutdownSignal = (signal: NodeJS.Signals) => {
|
||||
void shutdown("signal").catch((error) => {
|
||||
console.error(
|
||||
`[co-mono-daemon] shutdown failed for ${signal}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
|
||||
process.once("SIGINT", () => handleShutdownSignal("SIGINT"));
|
||||
process.once("SIGTERM", () => handleShutdownSignal("SIGTERM"));
|
||||
process.once("SIGQUIT", () => handleShutdownSignal("SIGQUIT"));
|
||||
process.once("SIGHUP", () => handleShutdownSignal("SIGHUP"));
|
||||
|
||||
process.on("unhandledRejection", (error) => {
|
||||
console.error(`[co-mono-daemon] unhandled rejection: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
|
||||
await session.bindExtensions({
|
||||
commandContextActions: createCommandContextActions(session),
|
||||
shutdownHandler: () => {
|
||||
void shutdown("extension").catch((error) => {
|
||||
console.error(
|
||||
`[co-mono-daemon] extension shutdown failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(`Extension error (${err.extensionPath}): ${err.error}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Emit structured events to stderr for supervisor logs.
|
||||
session.subscribe((event) => {
|
||||
console.error(
|
||||
JSON.stringify({ type: event.type, sessionId: session.sessionId, messageCount: session.messages.length }),
|
||||
);
|
||||
});
|
||||
|
||||
// Startup probes/messages.
|
||||
if (initialMessage) {
|
||||
await session.prompt(initialMessage, { images: initialImages });
|
||||
}
|
||||
for (const message of messages) {
|
||||
await session.prompt(message);
|
||||
}
|
||||
|
||||
console.error(`[co-mono-daemon] startup complete (session=${session.sessionId ?? "unknown"})`);
|
||||
|
||||
// Keep process alive forever.
|
||||
await ready;
|
||||
process.exit(0);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
* Run modes for the coding agent.
|
||||
*/
|
||||
|
||||
export { type DaemonModeOptions, runDaemonMode } from "./daemon-mode.js";
|
||||
export { InteractiveMode, type InteractiveModeOptions } from "./interactive/interactive-mode.js";
|
||||
export { type PrintModeOptions, runPrintMode } from "./print-mode.js";
|
||||
export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client.js";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue