diff --git a/packages/daemon/src/config.ts b/packages/daemon/src/config.ts index 8b2c6063a..c1dddbbd6 100644 --- a/packages/daemon/src/config.ts +++ b/packages/daemon/src/config.ts @@ -57,7 +57,17 @@ export function validateConfig(raw: unknown): DaemonConfig { guild_id: typeof d['guild_id'] === 'string' ? d['guild_id'] : '', owner_id: typeof d['owner_id'] === 'string' ? d['owner_id'] : '', ...(typeof d['dm_on_blocker'] === 'boolean' ? { dm_on_blocker: d['dm_on_blocker'] } : {}), + ...(typeof d['control_channel_id'] === 'string' ? { control_channel_id: d['control_channel_id'] } : {}), }; + + // Parse orchestrator sub-block + if (d['orchestrator'] != null && typeof d['orchestrator'] === 'object') { + const orc = d['orchestrator'] as Record; + discord.orchestrator = { + ...(typeof orc['model'] === 'string' ? { model: orc['model'] } : {}), + ...(typeof orc['max_tokens'] === 'number' && orc['max_tokens'] > 0 ? { max_tokens: orc['max_tokens'] } : {}), + }; + } } // --- projects --- diff --git a/packages/daemon/src/daemon.ts b/packages/daemon/src/daemon.ts index 5d94097ca..f5bedfacb 100644 --- a/packages/daemon/src/daemon.ts +++ b/packages/daemon/src/daemon.ts @@ -4,6 +4,7 @@ import { SessionManager } from './session-manager.js'; import { scanForProjects } from './project-scanner.js'; import { DiscordBot, validateDiscordConfig } from './discord-bot.js'; import { EventBridge } from './event-bridge.js'; +import { Orchestrator } from './orchestrator.js'; /** * Core daemon class — ties config + logger together with lifecycle management. @@ -17,6 +18,7 @@ export class Daemon { private sessionManager: SessionManager | undefined; private discordBot: DiscordBot | undefined; private eventBridge: EventBridge | undefined; + private orchestrator: Orchestrator | undefined; constructor( private readonly config: DaemonConfig, @@ -51,6 +53,7 @@ export class Daemon { config: this.config.discord, logger: this.logger, sessionManager: this.sessionManager, + scanProjects: () => this.scanProjects(), }); await this.discordBot.login(); @@ -69,6 +72,28 @@ export class Daemon { this.discordBot.setEventBridge(this.eventBridge); this.eventBridge.start(); this.logger.info('event bridge wired'); + + // Wire up Orchestrator if control_channel_id is configured + if (this.config.discord.control_channel_id) { + this.orchestrator = new Orchestrator({ + sessionManager: this.sessionManager, + channelManager, + scanProjects: () => this.scanProjects(), + config: { + model: this.config.discord.orchestrator?.model ?? 'claude-sonnet-4-5-20250929', + max_tokens: this.config.discord.orchestrator?.max_tokens ?? 1024, + control_channel_id: this.config.discord.control_channel_id, + }, + logger: this.logger, + ownerId: this.config.discord.owner_id, + }); + client.on('messageCreate', (message) => { + void this.orchestrator!.handleMessage(message); + }); + this.logger.info('orchestrator wired', { + control_channel_id: this.config.discord.control_channel_id, + }); + } } else { this.logger.warn('event bridge skipped — channel manager or client not available'); } @@ -100,6 +125,11 @@ export class Daemon { return this.eventBridge; } + /** Accessor for the orchestrator (available after start() with control_channel_id configured). */ + getOrchestrator(): Orchestrator | undefined { + return this.orchestrator; + } + /** Idempotent shutdown: log, cleanup sessions, close logger, exit. */ async shutdown(): Promise { if (this.shuttingDown) return; @@ -117,6 +147,12 @@ export class Daemon { this.keepaliveTimer = undefined; } + // Stop Orchestrator first + if (this.orchestrator) { + this.orchestrator.stop(); + this.orchestrator = undefined; + } + // Stop EventBridge before Discord bot destroy if (this.eventBridge) { await this.eventBridge.stop(); diff --git a/packages/daemon/src/discord-bot.test.ts b/packages/daemon/src/discord-bot.test.ts index 648a355c2..e450fd885 100644 --- a/packages/daemon/src/discord-bot.test.ts +++ b/packages/daemon/src/discord-bot.test.ts @@ -10,6 +10,7 @@ import { sanitizeChannelName, ChannelManager } from './channel-manager.js'; import { buildCommands, formatSessionStatus } from './commands.js'; import { Daemon } from './daemon.js'; import { Logger } from './logger.js'; +import { validateConfig } from './config.js'; import type { DaemonConfig, LogEntry, ManagedSession } from './types.js'; // ---------- helpers ---------- @@ -573,3 +574,219 @@ describe('command dispatch', () => { assert.equal(authorized, true); }); }); + +// ---------- Config validation: new fields ---------- + +describe('validateConfig — control_channel_id and orchestrator', () => { + it('parses control_channel_id from discord block', () => { + const config = validateConfig({ + discord: { + token: 'tok', + guild_id: 'g1', + owner_id: 'o1', + control_channel_id: 'ch-123', + }, + }); + assert.equal(config.discord?.control_channel_id, 'ch-123'); + }); + + it('omits control_channel_id when not present', () => { + const config = validateConfig({ + discord: { + token: 'tok', + guild_id: 'g1', + owner_id: 'o1', + }, + }); + assert.equal(config.discord?.control_channel_id, undefined); + }); + + it('parses orchestrator model and max_tokens', () => { + const config = validateConfig({ + discord: { + token: 'tok', + guild_id: 'g1', + owner_id: 'o1', + orchestrator: { model: 'claude-opus-2025', max_tokens: 2048 }, + }, + }); + assert.equal(config.discord?.orchestrator?.model, 'claude-opus-2025'); + assert.equal(config.discord?.orchestrator?.max_tokens, 2048); + }); + + it('missing orchestrator block results in undefined', () => { + const config = validateConfig({ + discord: { + token: 'tok', + guild_id: 'g1', + owner_id: 'o1', + }, + }); + assert.equal(config.discord?.orchestrator, undefined); + }); + + it('empty orchestrator block has no model or max_tokens', () => { + const config = validateConfig({ + discord: { + token: 'tok', + guild_id: 'g1', + owner_id: 'o1', + orchestrator: {}, + }, + }); + // orchestrator object should exist but with no values set + assert.ok(config.discord?.orchestrator !== undefined); + assert.equal(config.discord?.orchestrator?.model, undefined); + assert.equal(config.discord?.orchestrator?.max_tokens, undefined); + }); + + it('ignores non-numeric max_tokens', () => { + const config = validateConfig({ + discord: { + token: 'tok', + guild_id: 'g1', + owner_id: 'o1', + orchestrator: { max_tokens: 'not a number' }, + }, + }); + assert.equal(config.discord?.orchestrator?.max_tokens, undefined); + }); + + it('ignores non-string model', () => { + const config = validateConfig({ + discord: { + token: 'tok', + guild_id: 'g1', + owner_id: 'o1', + orchestrator: { model: 42 }, + }, + }); + assert.equal(config.discord?.orchestrator?.model, undefined); + }); +}); + +// ---------- Daemon wiring: orchestrator ---------- + +describe('Daemon orchestrator wiring', () => { + it('orchestrator is undefined when control_channel_id is not set', async () => { + const dir = tmpDir(); + cleanupDirs.push(dir); + const logPath = join(dir, 'no-orchestrator.log'); + + const config: DaemonConfig = { + discord: undefined, + projects: { scan_roots: [] }, + log: { file: logPath, level: 'debug', max_size_mb: 50 }, + }; + + const logger = new Logger({ filePath: logPath, level: 'debug' }); + const daemon = new Daemon(config, logger); + + await daemon.start(); + assert.equal(daemon.getOrchestrator(), undefined); + + const origExit = process.exit; + // @ts-expect-error — overriding process.exit for test + process.exit = () => {}; + try { + await daemon.shutdown(); + } finally { + process.exit = origExit; + } + }); + + it('orchestrator is undefined when discord has no control_channel_id', async () => { + // Even with a discord block that fails login, orchestrator should not be created + // because there's no control_channel_id + const dir = tmpDir(); + cleanupDirs.push(dir); + const logPath = join(dir, 'no-ctl-chan.log'); + + const config: DaemonConfig = { + discord: { + token: 'bad-token', + guild_id: 'g1', + owner_id: 'o1', + // control_channel_id intentionally omitted + }, + projects: { scan_roots: [] }, + log: { file: logPath, level: 'debug', max_size_mb: 50 }, + }; + + const logger = new Logger({ filePath: logPath, level: 'debug' }); + const daemon = new Daemon(config, logger); + + await daemon.start(); + // Login fails, so orchestrator can't be wired regardless. But the code path + // that checks control_channel_id comes after successful login/eventBridge wiring. + // Since login fails, orchestrator is undefined. + assert.equal(daemon.getOrchestrator(), undefined); + + const origExit = process.exit; + // @ts-expect-error — overriding process.exit for test + process.exit = () => {}; + try { + await daemon.shutdown(); + } finally { + process.exit = origExit; + } + }); +}); + +// ---------- /gsd-start and /gsd-stop logic paths ---------- + +describe('/gsd-start and /gsd-stop logic', () => { + // These test the observable logic paths exercised by the handlers. + // Since handleGsdStart/handleGsdStop are private, we test the data layer + // they depend on — project scanning, session listing, and edge cases. + + it('/gsd-start: scanForProjects returning 0 projects', async () => { + // Simulates the "no projects" path + const { scanForProjects } = await import('./project-scanner.js'); + // With no scan roots, should return empty + const projects = await scanForProjects([]); + assert.equal(projects.length, 0); + }); + + it('/gsd-stop: getAllSessions returns empty when no sessions active', async () => { + const { SessionManager } = await import('./session-manager.js'); + const dir = tmpDir(); + cleanupDirs.push(dir); + const logPath = join(dir, 'sm-test.log'); + const logger = new Logger({ filePath: logPath, level: 'debug' }); + const sm = new SessionManager(logger); + const sessions = sm.getAllSessions(); + assert.equal(sessions.length, 0); + await logger.close(); + }); + + it('/gsd-stop: filters to active sessions only', () => { + // Simulate the filter logic used in handleGsdStop + const allSessions: Partial[] = [ + { sessionId: 's1', status: 'running', projectName: 'alpha' }, + { sessionId: 's2', status: 'completed', projectName: 'beta' }, + { sessionId: 's3', status: 'blocked', projectName: 'gamma' }, + { sessionId: 's4', status: 'error', projectName: 'delta' }, + { sessionId: 's5', status: 'starting', projectName: 'epsilon' }, + { sessionId: 's6', status: 'cancelled', projectName: 'zeta' }, + ]; + const active = allSessions.filter( + (s) => s.status === 'running' || s.status === 'blocked' || s.status === 'starting', + ); + assert.equal(active.length, 3); + assert.deepEqual(active.map((s) => s.projectName), ['alpha', 'gamma', 'epsilon']); + }); + + it('/gsd-start: >25 projects are truncated for select menu', () => { + // Simulate the truncation logic + const projects = Array.from({ length: 30 }, (_, i) => ({ + name: `project-${i}`, + path: `/home/user/project-${i}`, + markers: [] as string[], + lastModified: Date.now(), + })); + const truncated = projects.slice(0, 25); + assert.equal(truncated.length, 25); + assert.equal(truncated[24].name, 'project-24'); + }); +}); diff --git a/packages/daemon/src/discord-bot.ts b/packages/daemon/src/discord-bot.ts index 1340dde5c..3edde49b5 100644 --- a/packages/daemon/src/discord-bot.ts +++ b/packages/daemon/src/discord-bot.ts @@ -10,10 +10,14 @@ import { Client, GatewayIntentBits, REST, + StringSelectMenuBuilder, + ActionRowBuilder, + ComponentType, type Interaction, type Guild, + type StringSelectMenuInteraction, } from 'discord.js'; -import type { DaemonConfig, VerbosityLevel } from './types.js'; +import type { DaemonConfig, VerbosityLevel, ProjectInfo } from './types.js'; import type { Logger } from './logger.js'; import type { SessionManager } from './session-manager.js'; import { ChannelManager } from './channel-manager.js'; @@ -62,6 +66,8 @@ export interface DiscordBotOptions { config: NonNullable; logger: Logger; sessionManager: SessionManager; + /** Optional function to scan for projects (passed from Daemon). */ + scanProjects?: () => Promise; } export class DiscordBot { @@ -73,11 +79,13 @@ export class DiscordBot { private readonly config: NonNullable; private readonly logger: Logger; private readonly sessionManager: SessionManager; + private readonly scanProjects?: () => Promise; constructor(opts: DiscordBotOptions) { this.config = opts.config; this.logger = opts.logger; this.sessionManager = opts.sessionManager; + this.scanProjects = opts.scanProjects; } /** @@ -223,9 +231,15 @@ export class DiscordBot { break; } case 'gsd-start': + this.handleGsdStart(interaction).catch((err) => { + this.logger.warn('gsd-start handler error', { + error: err instanceof Error ? err.message : String(err), + }); + }); + break; case 'gsd-stop': - interaction.reply({ content: 'Coming soon — use #gsd-control', ephemeral: true }).catch((err) => { - this.logger.warn(`${commandName} reply failed`, { + this.handleGsdStop(interaction).catch((err) => { + this.logger.warn('gsd-stop handler error', { error: err instanceof Error ? err.message : String(err), }); }); @@ -258,4 +272,150 @@ export class DiscordBot { break; } } + + // --------------------------------------------------------------------------- + // Private: /gsd-start handler + // --------------------------------------------------------------------------- + + private async handleGsdStart(interaction: import('discord.js').ChatInputCommandInteraction): Promise { + await interaction.deferReply({ ephemeral: true }); + this.logger.info('gsd-start: scanning projects'); + + if (!this.scanProjects) { + await interaction.editReply({ content: 'Project scanning not available.' }); + return; + } + + let projects: ProjectInfo[]; + try { + projects = await this.scanProjects(); + } catch (err) { + this.logger.error('gsd-start: scan failed', { + error: err instanceof Error ? err.message : String(err), + }); + await interaction.editReply({ content: 'Failed to scan for projects.' }); + return; + } + + if (projects.length === 0) { + await interaction.editReply({ content: 'No projects found.' }); + return; + } + + // Discord select menus support max 25 options + const truncated = projects.slice(0, 25); + const select = new StringSelectMenuBuilder() + .setCustomId('gsd-start-select') + .setPlaceholder('Select a project to start') + .addOptions( + truncated.map((p) => ({ + label: p.name.slice(0, 100), // Discord label max 100 chars + value: p.path, + description: p.markers.join(', ').slice(0, 100) || undefined, + })), + ); + + const row = new ActionRowBuilder().addComponents(select); + const reply = await interaction.editReply({ + content: `Select a project to start (${truncated.length}${projects.length > 25 ? ` of ${projects.length}` : ''} projects):`, + components: [row], + }); + + try { + const collected = await reply.awaitMessageComponent({ + componentType: ComponentType.StringSelect, + time: 60_000, + filter: (i) => i.user.id === interaction.user.id, + }) as StringSelectMenuInteraction; + + const projectPath = collected.values[0]; + this.logger.info('gsd-start: project selected', { projectPath }); + + try { + const sessionId = await this.sessionManager.startSession({ projectDir: projectPath }); + await collected.update({ + content: `✅ Session started for **${projectPath}** (ID: \`${sessionId}\`)`, + components: [], + }); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + this.logger.error('gsd-start: startSession failed', { error: errMsg, projectPath }); + await collected.update({ + content: `❌ Failed to start session: ${errMsg}`, + components: [], + }); + } + } catch { + // Timeout or other collector error + this.logger.info('gsd-start: selection timed out'); + await interaction.editReply({ content: 'Selection timed out.', components: [] }); + } + } + + // --------------------------------------------------------------------------- + // Private: /gsd-stop handler + // --------------------------------------------------------------------------- + + private async handleGsdStop(interaction: import('discord.js').ChatInputCommandInteraction): Promise { + await interaction.deferReply({ ephemeral: true }); + this.logger.info('gsd-stop: listing sessions'); + + const allSessions = this.sessionManager.getAllSessions(); + const activeSessions = allSessions.filter( + (s) => s.status === 'running' || s.status === 'blocked' || s.status === 'starting', + ); + + if (activeSessions.length === 0) { + await interaction.editReply({ content: 'No active sessions.' }); + return; + } + + // Discord select menus support max 25 options + const truncated = activeSessions.slice(0, 25); + const select = new StringSelectMenuBuilder() + .setCustomId('gsd-stop-select') + .setPlaceholder('Select a session to stop') + .addOptions( + truncated.map((s) => ({ + label: `${s.projectName} (${s.status})`.slice(0, 100), + value: s.sessionId, + })), + ); + + const row = new ActionRowBuilder().addComponents(select); + const reply = await interaction.editReply({ + content: `Select a session to stop (${truncated.length} active):`, + components: [row], + }); + + try { + const collected = await reply.awaitMessageComponent({ + componentType: ComponentType.StringSelect, + time: 60_000, + filter: (i) => i.user.id === interaction.user.id, + }) as StringSelectMenuInteraction; + + const sessionId = collected.values[0]; + this.logger.info('gsd-stop: session selected', { sessionId }); + + try { + await this.sessionManager.cancelSession(sessionId); + await collected.update({ + content: `✅ Session \`${sessionId}\` stopped.`, + components: [], + }); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + this.logger.error('gsd-stop: cancelSession failed', { error: errMsg, sessionId }); + await collected.update({ + content: `❌ Failed to stop session: ${errMsg}`, + components: [], + }); + } + } catch { + // Timeout or other collector error + this.logger.info('gsd-stop: selection timed out'); + await interaction.editReply({ content: 'Selection timed out.', components: [] }); + } + } } diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 5bce1c226..978f67b8f 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -26,6 +26,8 @@ export type { ChannelManagerOptions } from './channel-manager.js'; export { buildCommands, formatSessionStatus, registerGuildCommands } from './commands.js'; export { EventBridge } from './event-bridge.js'; export type { BridgeClient, EventBridgeOptions } from './event-bridge.js'; +export { Orchestrator } from './orchestrator.js'; +export type { OrchestratorConfig, OrchestratorDeps, DiscordMessageLike } from './orchestrator.js'; export { MessageBatcher } from './message-batcher.js'; export type { SendPayload, SendFn, BatcherLogger, BatcherOptions } from './message-batcher.js'; export { VerbosityManager, shouldShowAtLevel } from './verbosity.js'; diff --git a/packages/daemon/src/types.ts b/packages/daemon/src/types.ts index 4dc74291e..822d1ff9b 100644 --- a/packages/daemon/src/types.ts +++ b/packages/daemon/src/types.ts @@ -34,6 +34,13 @@ export interface DaemonConfig { owner_id: string; /** When true, DM the owner on blocker events in addition to channel messages */ dm_on_blocker?: boolean; + /** Discord channel ID where the orchestrator listens for natural language commands */ + control_channel_id?: string; + /** LLM orchestrator settings */ + orchestrator?: { + model?: string; + max_tokens?: number; + }; }; projects: { scan_roots: string[];