aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
1,155 lines (1,003 loc) • 37 kB
JavaScript
import fs from 'fs';
import path from 'path';
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import { createClient } from './ipc-client.mjs';
import { TmuxManager, formatStatusDashboard } from './tmux-manager.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PID_FILE = '.aiwg/daemon.pid';
const LOG_FILE = '.aiwg/daemon/daemon.log';
const STATE_FILE = '.aiwg/daemon/state.json';
const SOCKET_PATH = '.aiwg/daemon/daemon.sock';
class DaemonCLI {
constructor() {
this.command = process.argv[2];
this.flags = this.parseFlags(process.argv.slice(3));
}
parseFlags(args) {
const flags = {};
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith('--')) {
const key = args[i].slice(2);
const value = args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true;
flags[key] = value;
if (value !== true) i++;
}
}
return flags;
}
async run() {
switch (this.command) {
case 'start':
await this.start();
break;
case 'stop':
await this.stop();
break;
case 'status':
await this.status();
break;
case 'logs':
await this.logs();
break;
case 'restart':
await this.restart();
break;
case 'attach':
await this.attach();
break;
case 'chat':
await this.chat();
break;
case 'task':
await this.task();
break;
case 'init':
await this.init();
break;
case 'rooms':
await this.rooms();
break;
case 'autonomous':
await this.autonomous();
break;
case 'schedule':
await this.schedule();
break;
case 'memory':
await this.memory();
break;
case 'pty':
await this.pty();
break;
default:
this.usage();
process.exit(1);
}
}
async start() {
if (this.flags.docker) {
await this.startDocker();
return;
}
if (this.isDaemonRunning()) {
const pid = this.readPidFile();
console.error(`Daemon is already running (PID: ${pid})`);
process.exit(1);
}
this.ensureDirectories();
if (this.flags.foreground) {
await this.startForeground();
} else {
await this.startDetached();
}
}
async startDocker() {
try {
const { ContainerManager } = await import('../docker/container-manager.mjs');
const manager = new ContainerManager({
projectDir: process.cwd(),
webPort: parseInt(this.flags.port || '7474', 10),
});
if (!manager.isDockerAvailable()) {
console.error('Docker is not available. Install Docker or run without --docker.');
process.exit(1);
}
const { containerId, containerName } = manager.start();
console.log(`Daemon started in Docker container: ${containerName} (${containerId})`);
console.log(`Web UI: http://localhost:${this.flags.port || 7474}`);
} catch (error) {
console.error(`Failed to start Docker container: ${error.message}`);
process.exit(1);
}
}
async startForeground() {
console.log('Starting daemon in foreground mode...');
const daemonScript = path.join(__dirname, 'daemon-main.mjs');
const child = spawn(process.execPath, [daemonScript], {
stdio: 'inherit',
env: process.env
});
child.on('exit', (code) => {
console.log(`Daemon exited with code ${code}`);
process.exit(code);
});
}
async startDetached() {
console.log('Starting daemon...');
const daemonScript = path.join(__dirname, 'daemon-main.mjs');
const logFd = fs.openSync(LOG_FILE, 'a');
const child = spawn(process.execPath, [daemonScript], {
detached: true,
stdio: ['ignore', logFd, logFd],
env: process.env
});
child.unref();
await new Promise(resolve => setTimeout(resolve, 1000));
if (this.isDaemonRunning()) {
const pid = this.readPidFile();
console.log(`Daemon started successfully (PID: ${pid})`);
console.log(`Logs: ${LOG_FILE}`);
} else {
console.error('Daemon failed to start. Check logs for details.');
process.exit(1);
}
}
async stop() {
if (!this.isDaemonRunning()) {
console.log('Daemon is not running');
return;
}
const pid = this.readPidFile();
console.log(`Stopping daemon (PID: ${pid})...`);
try {
process.kill(pid, 'SIGTERM');
const maxWait = 10000;
const checkInterval = 500;
let waited = 0;
while (waited < maxWait) {
await new Promise(resolve => setTimeout(resolve, checkInterval));
waited += checkInterval;
if (!this.isPidAlive(pid)) {
console.log('Daemon stopped gracefully');
this.cleanup();
return;
}
}
console.log('Daemon did not stop gracefully, sending SIGKILL...');
process.kill(pid, 'SIGKILL');
await new Promise(resolve => setTimeout(resolve, 1000));
if (!this.isPidAlive(pid)) {
console.log('Daemon killed');
this.cleanup();
} else {
console.error('Failed to stop daemon');
process.exit(1);
}
} catch (error) {
if (error.code === 'ESRCH') {
console.log('Daemon process not found (stale PID file)');
this.cleanup();
} else {
console.error(`Failed to stop daemon: ${error.message}`);
process.exit(1);
}
}
}
async status() {
if (!this.isDaemonRunning()) {
console.log('Daemon: stopped');
return;
}
const pid = this.readPidFile();
console.log(`Daemon: running (PID: ${pid})`);
// Try live IPC query first, fall back to state file
try {
const client = await createClient(SOCKET_PATH);
const state = await client.request('daemon.status');
client.disconnect();
console.log(`Started: ${state.started_at}`);
console.log(`Uptime: ${state.uptime_seconds}s`);
console.log(`Health: ${state.health}`);
const sub = state.subsystems;
console.log(`\nIPC clients: ${sub.ipc?.clients || 0}`);
if (sub.supervisor) {
console.log(`\nAgents:`);
console.log(` Running: ${sub.supervisor.running}`);
console.log(` Queued: ${sub.supervisor.queued}`);
console.log(` Max concurrent: ${sub.supervisor.maxConcurrency}`);
}
if (sub.automation?.enabled) {
console.log(`\nAutomation: enabled (${sub.automation.ruleCount} rules)`);
}
if (sub.file_watcher?.enabled) {
console.log(`\nFile watcher: enabled (${sub.file_watcher.events_received} events)`);
}
if (sub.scheduler?.enabled) {
console.log(`Scheduler: enabled (${sub.scheduler.jobs} jobs)`);
}
if (sub.messaging?.enabled) {
console.log(`Messaging: enabled (${sub.messaging.adapters} adapters)`);
}
return;
} catch {
// IPC unavailable, fall back to state file
}
if (fs.existsSync(STATE_FILE)) {
try {
const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
console.log(`Started: ${state.started_at}`);
console.log(`Uptime: ${state.uptime_seconds}s`);
console.log(`Last heartbeat: ${state.last_heartbeat}`);
console.log(`Health: ${state.health.status}`);
if (state.agents) {
console.log(`\nAgents:`);
console.log(` Running: ${state.agents.running}`);
console.log(` Queued: ${state.agents.queued}`);
}
console.log('\nMonitors:');
if (state.monitors.file_watcher?.enabled) {
console.log(` File watcher: enabled (${state.monitors.file_watcher.events_received} events)`);
}
if (state.monitors.scheduler?.enabled) {
console.log(` Scheduler: enabled (${state.monitors.scheduler.jobs} jobs)`);
}
if (state.health.issues.length > 0) {
console.log('\nIssues:');
for (const issue of state.health.issues) {
console.log(` - ${issue}`);
}
}
} catch (error) {
console.error(`Failed to read state file: ${error.message}`);
}
}
}
async logs() {
if (!fs.existsSync(LOG_FILE)) {
console.log('No log file found');
return;
}
const lines = parseInt(this.flags.lines || '50', 10);
const follow = this.flags.follow || this.flags.f;
if (follow) {
const tail = spawn('tail', ['-f', '-n', String(lines), LOG_FILE], {
stdio: 'inherit'
});
process.on('SIGINT', () => {
tail.kill();
process.exit(0);
});
} else {
const content = fs.readFileSync(LOG_FILE, 'utf8');
const allLines = content.split('\n');
const tailLines = allLines.slice(-lines);
console.log(tailLines.join('\n'));
}
}
async restart() {
console.log('Restarting daemon...');
await this.stop();
await new Promise(resolve => setTimeout(resolve, 1000));
await this.start();
}
async attach() {
const tmux = new TmuxManager({ sessionName: 'aiwg-daemon' });
if (!tmux.isTmuxAvailable()) {
console.error('tmux is not installed. Install with: apt install tmux');
process.exit(1);
}
// If daemon is not running, start it first
if (!this.isDaemonRunning()) {
console.log('Daemon is not running. Starting...');
await this.startDetached();
await new Promise(resolve => setTimeout(resolve, 500));
}
if (!tmux.sessionExists()) {
// Create session with REPL in top pane, status in bottom pane
const replScript = path.join(__dirname, 'repl-entry.mjs');
const chatCommand = `${process.execPath} ${replScript}`;
const statusCommand = `watch -n 5 "cat ${STATE_FILE} 2>/dev/null | node -e \\"process.stdin.on('data',d=>{try{const s=JSON.parse(d);console.log('Agents:',s.agents?.running||0,'running,',s.agents?.queued||0,'queued');console.log('Uptime:',s.uptime_seconds+'s')}catch{}})\\" 2>/dev/null || echo 'Waiting for daemon...'"`;
try {
tmux.createSession({
chatCommand,
statusCommand,
});
console.log('Created tmux session: aiwg-daemon');
} catch (error) {
console.error(`Failed to create session: ${error.message}`);
process.exit(1);
}
}
try {
await tmux.attach(this.flags.readonly === true);
} catch (error) {
console.error(`Failed to attach: ${error.message}`);
process.exit(1);
}
}
async chat() {
if (!this.isDaemonRunning()) {
console.error('Daemon is not running. Start with: aiwg daemon start');
process.exit(1);
}
const message = process.argv.slice(3).join(' ');
if (!message) {
console.error('Usage: aiwg daemon chat <message>');
process.exit(1);
}
try {
const client = await createClient(SOCKET_PATH);
const result = await client.request('chat.send', { message });
client.disconnect();
console.log(`Task submitted: ${result.taskId}`);
} catch (error) {
console.error(`Failed to send message: ${error.message}`);
process.exit(1);
}
}
async init() {
try {
const { initProfile, listProfiles } = await import('./init-profile.mjs');
const profileName = process.argv[3] || 'manager';
if (profileName === 'list') {
const profiles = listProfiles();
console.log('Available profiles:');
for (const p of profiles) {
console.log(` ${p}`);
}
return;
}
const result = initProfile({
profile: profileName,
force: this.flags.force === true,
});
console.log(`Daemon config initialized from "${result.profile}" profile`);
console.log(` Config: ${result.configPath}`);
if (result.envPath) {
console.log(` Env template: ${result.envPath}`);
}
console.log('\nNext steps:');
console.log(' 1. Edit .aiwg/daemon.yaml to configure messaging tokens and rooms');
console.log(' 2. Copy .env.example to .env and fill in your values');
console.log(' 3. Run: aiwg daemon start');
} catch (error) {
console.error(`Init failed: ${error.message}`);
process.exit(1);
}
}
async rooms() {
if (!this.isDaemonRunning()) {
console.error('Daemon is not running');
process.exit(1);
}
try {
const client = await createClient(SOCKET_PATH);
const status = await client.request('rooms.list');
client.disconnect();
if (!status || status.totalRooms === 0) {
console.log('No rooms configured');
return;
}
console.log(`Rooms: ${status.totalRooms} total, ${status.totalBindings} task binding(s)\n`);
for (const [platform, rooms] of Object.entries(status.platforms || {})) {
console.log(`${platform}:`);
for (const room of rooms) {
const defaultTag = room.isDefault ? ' [default]' : '';
const tasks = room.boundTasks.length > 0 ? ` (tasks: ${room.boundTasks.join(', ')})` : '';
console.log(` ${room.label} (${room.id}) — ${room.purpose}${defaultTag}${tasks}`);
}
}
} catch (error) {
console.error(`Failed to list rooms: ${error.message}`);
process.exit(1);
}
}
async autonomous() {
const subcommand = process.argv[3];
if (!this.isDaemonRunning()) {
console.error('Daemon is not running');
process.exit(1);
}
try {
const client = await createClient(SOCKET_PATH);
switch (subcommand) {
case 'enable': {
const result = await client.request('autonomous.enable');
console.log(`Autonomous mode: ${result.enabled ? 'enabled' : 'disabled'}`);
break;
}
case 'disable': {
const result = await client.request('autonomous.disable');
console.log(`Autonomous mode: ${result.enabled ? 'enabled' : 'disabled'}`);
break;
}
case 'status':
default: {
const status = await client.request('autonomous.status');
console.log(`Autonomous mode: ${status.enabled ? 'enabled' : 'disabled'}`);
if (status.enabled) {
console.log(` Running: ${status.running}`);
console.log(` Daily tasks: ${status.dailyTaskCount}/${status.dailyTaskLimit}`);
console.log(` Daily spend: $${status.dailySpendUsd.toFixed(2)}/$${status.budgetCapUsd.toFixed(2)}`);
console.log(` Pending proposals: ${status.pendingProposals}`);
console.log(` Require approval: ${status.requireApproval}`);
console.log(` Allowed actions: ${status.allowedActions.join(', ')}`);
}
break;
}
}
client.disconnect();
} catch (error) {
console.error(`Failed: ${error.message}`);
process.exit(1);
}
}
async schedule() {
if (!this.isDaemonRunning()) {
console.error('Daemon is not running');
process.exit(1);
}
try {
const client = await createClient(SOCKET_PATH);
const status = await client.request('schedule.status');
client.disconnect();
console.log(`Scheduler: ${status.started ? 'running' : 'stopped'}`);
console.log(`Registered actions: ${status.registeredActions.join(', ')}`);
if (status.jobHistory.length > 0) {
console.log('\nJob history:');
for (const job of status.jobHistory) {
console.log(` ${job.jobId}: ${job.action} (runs: ${job.runCount}, last: ${job.lastRun})`);
}
}
} catch (error) {
console.error(`Failed: ${error.message}`);
process.exit(1);
}
}
async memory() {
const subcommand = process.argv[3];
switch (subcommand) {
case 'show': {
const scope = this.flags.scope;
if (this.isDaemonRunning()) {
// Live: query daemon via IPC
try {
const client = await createClient(SOCKET_PATH);
const result = await client.request('memory.show', scope ? { scope } : {});
client.disconnect();
this._printMemoryShow(result);
return;
} catch {
// Fallback to offline read below
}
}
// Offline: read files directly
const { MemoryManager } = await import('./memory-manager.mjs');
const mgr = new MemoryManager();
this._printMemoryShow(mgr.show(scope));
break;
}
case 'clear': {
const scope = this.flags.scope;
if (!scope) {
console.error('Usage: aiwg daemon memory clear --scope <session|project|user>');
process.exit(1);
}
if (this.isDaemonRunning()) {
try {
const client = await createClient(SOCKET_PATH);
const result = await client.request('memory.clear', { scope });
client.disconnect();
console.log(`Cleared ${result.cleared} entry/entries from ${scope} scope`);
return;
} catch (error) {
console.error(`IPC failed, attempting offline clear: ${error.message}`);
}
}
// Offline clear
const { MemoryManager } = await import('./memory-manager.mjs');
const mgr = new MemoryManager();
try {
const result = mgr.clear(scope);
console.log(`Cleared ${result.cleared} entry/entries from ${scope} scope`);
} catch (error) {
console.error(`Failed to clear memory: ${error.message}`);
process.exit(1);
}
break;
}
default:
console.log(`
Usage: aiwg daemon memory <subcommand> [options]
Subcommands:
show Show memory contents (all scopes or filtered by --scope)
clear Clear memory entries (requires --scope)
Options:
--scope <scope> Memory scope: session | project | user
Examples:
aiwg daemon memory show
aiwg daemon memory show --scope user
aiwg daemon memory clear --scope session
aiwg daemon memory clear --scope project
`.trim());
if (subcommand) process.exit(1);
}
}
_printMemoryShow(result) {
const scopes = ['session', 'project', 'user'];
let any = false;
for (const scope of scopes) {
const info = result[scope];
if (!info) continue;
console.log(`\n${scope.toUpperCase()} MEMORY`);
if (info.path) console.log(` Path: ${info.path}`);
console.log(` Entries: ${info.count}`);
if (info.entries && info.entries.length > 0) {
for (const e of info.entries) {
const name = e.name || e.key || '(unnamed)';
const desc = e.description ? ` — ${e.description}` : '';
console.log(` • ${name}${desc}`);
}
any = true;
}
}
if (!any) console.log('\n(no memory entries)');
}
async task() {
const subcommand = process.argv[3];
const args = process.argv.slice(4);
switch (subcommand) {
case 'submit': {
if (!this.isDaemonRunning()) {
console.error('Daemon is not running. Start with: aiwg daemon start');
process.exit(1);
}
const prompt = args.join(' ');
if (!prompt) {
console.error('Usage: aiwg daemon task submit <prompt>');
process.exit(1);
}
try {
const client = await createClient(SOCKET_PATH);
const result = await client.request('task.submit', {
prompt,
agent: this.flags.agent,
priority: this.flags.priority ? parseInt(this.flags.priority, 10) : 0,
});
client.disconnect();
console.log(`Task submitted: ${result.taskId} (${result.state})`);
} catch (error) {
console.error(`Failed to submit task: ${error.message}`);
process.exit(1);
}
break;
}
case 'list': {
if (!this.isDaemonRunning()) {
console.error('Daemon is not running');
process.exit(1);
}
try {
const client = await createClient(SOCKET_PATH);
const tasks = await client.request('task.list', {
state: this.flags.state,
limit: this.flags.limit ? parseInt(this.flags.limit, 10) : 20,
});
client.disconnect();
if (!tasks || tasks.length === 0) {
console.log('No tasks found');
return;
}
console.log(`${'ID'.padEnd(12)} ${'State'.padEnd(12)} ${'Prompt'.padEnd(50)}`);
console.log('-'.repeat(74));
for (const task of tasks) {
const prompt = (task.prompt || '').slice(0, 48);
console.log(`${(task.id || '').padEnd(12)} ${(task.state || '').padEnd(12)} ${prompt}`);
}
} catch (error) {
console.error(`Failed to list tasks: ${error.message}`);
process.exit(1);
}
break;
}
case 'get': {
const taskId = args[0];
if (!taskId) {
console.error('Usage: aiwg daemon task get <taskId>');
process.exit(1);
}
if (!this.isDaemonRunning()) {
console.error('Daemon is not running');
process.exit(1);
}
try {
const client = await createClient(SOCKET_PATH);
const task = await client.request('task.get', { taskId });
client.disconnect();
console.log(JSON.stringify(task, null, 2));
} catch (error) {
console.error(`Failed to get task: ${error.message}`);
process.exit(1);
}
break;
}
case 'cancel': {
const taskId = args[0];
if (!taskId) {
console.error('Usage: aiwg daemon task cancel <taskId>');
process.exit(1);
}
if (!this.isDaemonRunning()) {
console.error('Daemon is not running');
process.exit(1);
}
try {
const client = await createClient(SOCKET_PATH);
const result = await client.request('task.cancel', { taskId });
client.disconnect();
console.log(result.cancelled ? `Task ${taskId} cancelled` : `Task ${taskId} not found or already completed`);
} catch (error) {
console.error(`Failed to cancel task: ${error.message}`);
process.exit(1);
}
break;
}
case 'stats': {
if (!this.isDaemonRunning()) {
console.error('Daemon is not running');
process.exit(1);
}
try {
const client = await createClient(SOCKET_PATH);
const stats = await client.request('task.stats');
client.disconnect();
console.log(JSON.stringify(stats, null, 2));
} catch (error) {
console.error(`Failed to get stats: ${error.message}`);
process.exit(1);
}
break;
}
default:
console.log(`
Usage: aiwg daemon task <subcommand> [options]
Subcommands:
submit <prompt> Submit a new agent task
list List tasks
get <taskId> Get task details
cancel <taskId> Cancel a task
stats Show task statistics
Options:
--agent <name> Agent type for task submission
--priority <n> Task priority (0=default, higher=sooner)
--state <state> Filter tasks by state (queued, running, completed, failed)
--limit <n> Max tasks to list (default: 20)
`.trim());
if (subcommand) process.exit(1);
}
}
async pty() {
const subcommand = process.argv[3];
const args = process.argv.slice(4);
switch (subcommand) {
case 'start': {
const platform = args[0];
if (!platform) {
console.error('Usage: aiwg daemon pty start <platform> [-- <args...>]');
console.error('Platforms: claude-code, opencode, codex, warp, openclaw');
process.exit(1);
}
const dashDash = args.indexOf('--');
const extraArgs = dashDash >= 0 ? args.slice(dashDash + 1) : [];
const cols = this.flags.cols ? parseInt(this.flags.cols, 10) : 80;
const rows = this.flags.rows ? parseInt(this.flags.rows, 10) : 24;
const useTmux = !!this.flags.tmux;
// --tmux path: spawn inside a named tmux session for reconnect support
if (useTmux) {
try {
const { execSync, execFileSync } = await import('child_process');
const { createHash, randomBytes } = await import('crypto');
const { PTYAdapter } = await import('./pty-adapter.mjs');
const sessionId = randomBytes(6).toString('hex');
const tmuxName = `aiwg-pty-${sessionId}`;
// Resolve platform binary
const PLATFORM_BINS = {
'claude-code': 'claude', opencode: 'opencode', codex: 'codex',
warp: 'warp-terminal', openclaw: 'openclaw',
};
const bin = PLATFORM_BINS[platform] || platform;
const fullCmd = [bin, ...extraArgs].join(' ');
// Start named tmux session detached
execSync(`tmux new-session -d -s "${tmuxName}" -x ${cols} -y ${rows} "${fullCmd}"`);
// Get the PID of the shell/process running inside the tmux pane
let pid = null;
try {
pid = parseInt(
execSync(`tmux list-panes -t "${tmuxName}" -F '#{pane_pid}'`).toString().trim(),
10,
);
} catch { /* leave null if tmux version doesn't support format strings */ }
// Write session file (compatible with PTYAdapter.getSession)
PTYAdapter._ensureSessionDir();
const sessionFile = path.join('.aiwg/daemon/pty', `${sessionId}.json`);
fs.writeFileSync(
sessionFile,
JSON.stringify({
sessionId, platform, bin,
pid, cols, rows,
tmuxSession: tmuxName,
cwd: process.cwd(),
started_at: new Date().toISOString(),
}, null, 2),
);
console.log(`PTY session started in tmux: ${sessionId}`);
console.log(` Platform: ${platform}`);
console.log(` tmux session: ${tmuxName}`);
console.log(` Reattach: aiwg daemon pty attach ${sessionId}`);
console.log(` Or directly: tmux attach-session -t ${tmuxName}`);
} catch (error) {
console.error(`Failed to start tmux PTY session: ${error.message}`);
console.error('Ensure tmux is installed: apt install tmux / brew install tmux');
process.exit(1);
}
break;
}
try {
const { PTYAdapter } = await import('./pty-adapter.mjs');
const adapter = new PTYAdapter({ platform, args: extraArgs, cols, rows });
adapter.on('data', (chunk) => process.stdout.write(chunk));
adapter.on('exit', ({ exitCode }) => {
console.error(`\nPTY session exited (code: ${exitCode})`);
process.exit(exitCode ?? 0);
});
const sessionId = await adapter.start();
console.error(`PTY session started: ${sessionId} (platform: ${platform}, PID: ${adapter.getPid()})`);
console.error('Input is forwarded to the PTY. Press Ctrl+C to stop.');
// Forward stdin to the PTY
if (process.stdin.isTTY) process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.on('data', (chunk) => {
// Ctrl+C sends SIGINT to the adapter, not to our process
if (chunk[0] === 0x03) {
adapter.stop().then(() => process.exit(0));
return;
}
try { adapter.write(chunk.toString()); } catch { /* session gone */ }
});
// Forward terminal resize events
process.stdout.on('resize', () => {
adapter.resize(process.stdout.columns, process.stdout.rows);
});
} catch (error) {
console.error(`Failed to start PTY session: ${error.message}`);
process.exit(1);
}
break;
}
case 'list': {
try {
const { PTYAdapter } = await import('./pty-adapter.mjs');
const sessions = PTYAdapter.list();
if (sessions.length === 0) {
console.log('No active PTY sessions');
return;
}
console.log(`Active PTY sessions (${sessions.length}):`);
console.log('');
for (const s of sessions) {
console.log(` ${s.sessionId} platform=${s.platform} pid=${s.pid} ${s.cols}x${s.rows} started=${s.started_at}`);
}
} catch (error) {
console.error(`Failed to list PTY sessions: ${error.message}`);
process.exit(1);
}
break;
}
case 'stop': {
const sessionId = args[0];
if (!sessionId) {
console.error('Usage: aiwg daemon pty stop <session-id>');
process.exit(1);
}
try {
const { PTYAdapter } = await import('./pty-adapter.mjs');
const session = PTYAdapter.getSession(sessionId);
if (!session) {
console.error(`Session not found or already stopped: ${sessionId}`);
process.exit(1);
}
try { process.kill(session.pid, 'SIGTERM'); } catch { /* already gone */ }
// Also stop tmux session if one was created
if (session.tmuxSession) {
try {
const { execSync } = await import('child_process');
execSync(`tmux kill-session -t ${session.tmuxSession} 2>/dev/null`, { stdio: 'ignore' });
} catch { /* tmux session may already be gone */ }
}
PTYAdapter._removeSession(sessionId);
console.log(`PTY session ${sessionId} stopped`);
} catch (error) {
console.error(`Failed to stop PTY session: ${error.message}`);
process.exit(1);
}
break;
}
case 'attach': {
const sessionId = args[0];
if (!sessionId) {
console.error('Usage: aiwg daemon pty attach <session-id>');
process.exit(1);
}
try {
const { PTYAdapter } = await import('./pty-adapter.mjs');
const session = PTYAdapter.getSession(sessionId);
if (!session) {
console.error(`Session not found or expired: ${sessionId}`);
console.error('List active sessions with: aiwg daemon pty list');
process.exit(1);
}
if (session.tmuxSession) {
// Attach via named tmux session
const proc = spawn('tmux', ['attach-session', '-t', session.tmuxSession], {
stdio: 'inherit',
});
proc.on('close', (code) => process.exit(code ?? 0));
proc.on('error', (err) => {
console.error(`Failed to attach to tmux session: ${err.message}`);
console.error(`Attach manually: tmux attach-session -t ${session.tmuxSession}`);
process.exit(1);
});
} else {
// No tmux session — show info and suggest --tmux for future sessions
console.log(`Session info:`);
console.log(` ID: ${session.sessionId}`);
console.log(` Platform: ${session.platform}`);
console.log(` PID: ${session.pid}`);
console.log(` Started: ${session.started_at}`);
console.log('');
console.log('This session was started without --tmux and cannot be reattached.');
console.log(`To start a reconnectable session: aiwg daemon pty start ${session.platform} --tmux`);
}
} catch (error) {
console.error(`Failed to attach: ${error.message}`);
process.exit(1);
}
break;
}
default:
console.log(`
Usage: aiwg daemon pty <subcommand> [options]
Subcommands:
start <platform> Spawn a platform TUI under a PTY and bridge I/O
attach <session-id> Reattach to a running PTY session (requires --tmux)
list List active PTY sessions
stop <session-id> Stop a PTY session
Platforms (Tier 1 — native daemon; Tier 2 — PTY adapter):
claude-code Claude Code CLI (Tier 1 + PTY adapter)
opencode OpenCode CLI (Tier 1)
codex OpenAI Codex CLI (Tier 1 + PTY adapter)
warp Warp Terminal (Tier 1)
openclaw OpenClaw CLI (Tier 1)
Options:
--cols <n> Terminal width (default: 80)
--rows <n> Terminal height (default: 24)
--tmux Start inside a named tmux session (enables reattach via 'pty attach')
Examples:
aiwg daemon pty start opencode
aiwg daemon pty start opencode --tmux # Start in tmux for reattach support
aiwg daemon pty attach abc123 # Reattach to a --tmux session
aiwg daemon pty start codex --cols 120 --rows 40
aiwg daemon pty start claude-code -- --dangerously-skip-permissions
aiwg daemon pty list
aiwg daemon pty stop abc123
Requirements:
PTY adapter (non-tmux) requires node-pty: npm install node-pty
node-pty needs native compilation (node-gyp + C++ build tools).
tmux mode requires tmux to be installed.
`.trim());
if (subcommand) process.exit(1);
}
}
isDaemonRunning() {
if (!fs.existsSync(PID_FILE)) {
return false;
}
const pid = this.readPidFile();
return this.isPidAlive(pid);
}
isPidAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return false;
}
}
readPidFile() {
try {
return parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
} catch (error) {
return null;
}
}
ensureDirectories() {
const dirs = [
'.aiwg',
'.aiwg/daemon',
'.aiwg/daemon/actions',
'.aiwg/daemon/events'
];
for (const dir of dirs) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
}
cleanup() {
try {
if (fs.existsSync(PID_FILE)) {
fs.unlinkSync(PID_FILE);
}
} catch (error) {
// Ignore cleanup errors
}
}
usage() {
console.log(`
Usage: aiwg daemon <command> [options]
Commands:
init [profile] Initialize daemon config from profile (default: manager)
start Start the daemon
stop Stop the daemon
status Show daemon status (live via IPC when available)
logs Show daemon logs
restart Restart the daemon
attach Open tmux session with chat REPL and status pane
pty <sub> PTY adapter — spawn a platform TUI over a chat bridge
chat <message> Send a message to the daemon agent
task <sub> Manage agent tasks (submit, list, get, cancel, stats)
rooms List connected messaging rooms
autonomous <sub> Manage autonomous mode (status, enable, disable)
schedule Show scheduled task status and history
memory <sub> Manage cross-session memory (show, clear)
Options:
--foreground Run in foreground (for Docker/systemd)
--docker Start daemon in Docker container
--force Overwrite existing config (for init)
--lines N Show last N lines of logs (default: 50)
--follow, -f Follow log output in real-time
--readonly Attach to tmux in read-only mode
Examples:
aiwg daemon init # Initialize with manager profile
aiwg daemon init list # List available profiles
aiwg daemon start
aiwg daemon start --docker # Start in Docker container
aiwg daemon start --foreground
aiwg daemon stop
aiwg daemon status
aiwg daemon rooms # List messaging rooms
aiwg daemon autonomous status # Check autonomous mode
aiwg daemon autonomous enable # Enable self-directed thinking
aiwg daemon schedule # Show scheduled tasks
aiwg daemon attach
aiwg daemon pty start opencode # Start OpenCode under PTY bridge
aiwg daemon pty start codex --cols 120 # Start Codex with wide terminal
aiwg daemon pty list # List active PTY sessions
aiwg daemon pty stop abc123 # Stop a specific PTY session
aiwg daemon chat "Review the latest PR"
aiwg daemon task submit "Run security audit"
aiwg daemon task list
aiwg daemon logs --follow
aiwg daemon restart
aiwg daemon memory show
aiwg daemon memory clear --scope session
`.trim());
}
}
const cli = new DaemonCLI();
cli.run().catch(error => {
console.error(`Error: ${error.message}`);
process.exit(1);
});