feat: Extended DaemonConfig with control_channel_id and orchestrator se…
- "packages/daemon/src/types.ts" - "packages/daemon/src/config.ts" - "packages/daemon/src/daemon.ts" - "packages/daemon/src/discord-bot.ts" - "packages/daemon/src/discord-bot.test.ts" - "packages/daemon/src/index.ts" GSD-Task: S05/T02
This commit is contained in:
parent
bbba5f83b9
commit
898e797772
6 changed files with 435 additions and 3 deletions
|
|
@ -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<string, unknown>;
|
||||
discord.orchestrator = {
|
||||
...(typeof orc['model'] === 'string' ? { model: orc['model'] } : {}),
|
||||
...(typeof orc['max_tokens'] === 'number' && orc['max_tokens'] > 0 ? { max_tokens: orc['max_tokens'] } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// --- projects ---
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<ManagedSession>[] = [
|
||||
{ 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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<DaemonConfig['discord']>;
|
||||
logger: Logger;
|
||||
sessionManager: SessionManager;
|
||||
/** Optional function to scan for projects (passed from Daemon). */
|
||||
scanProjects?: () => Promise<ProjectInfo[]>;
|
||||
}
|
||||
|
||||
export class DiscordBot {
|
||||
|
|
@ -73,11 +79,13 @@ export class DiscordBot {
|
|||
private readonly config: NonNullable<DaemonConfig['discord']>;
|
||||
private readonly logger: Logger;
|
||||
private readonly sessionManager: SessionManager;
|
||||
private readonly scanProjects?: () => Promise<ProjectInfo[]>;
|
||||
|
||||
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<void> {
|
||||
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<StringSelectMenuBuilder>().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<void> {
|
||||
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<StringSelectMenuBuilder>().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: [] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue