daemon with coding agent

This commit is contained in:
Harivansh Rathi 2026-03-05 17:26:42 -08:00
parent a20a72cd2e
commit 3e72ca7f4b
7 changed files with 610 additions and 279 deletions

View file

@ -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 |

View file

@ -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

View file

@ -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,

View 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);
}

View file

@ -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";