diff --git a/gsd-orchestrator/SKILL.md b/gsd-orchestrator/SKILL.md
index 6d828cde4..ad423afdf 100644
--- a/gsd-orchestrator/SKILL.md
+++ b/gsd-orchestrator/SKILL.md
@@ -115,15 +115,101 @@ GSD creates and manages all state in `.gsd/`:
PROJECT.md # What this project is
REQUIREMENTS.md # Capability contract
DECISIONS.md # Architectural decisions (append-only)
+ KNOWLEDGE.md # Persistent project knowledge (patterns, rules, lessons)
STATE.md # Current phase and next action
milestones/
M001-xxxxx/
M001-xxxxx-CONTEXT.md # Scope, constraints, assumptions
M001-xxxxx-ROADMAP.md # Slices with checkboxes
+ M001-xxxxx-SUMMARY.md # Completion summary
slices/S01/
S01-PLAN.md # Tasks
- tasks/T01-PLAN.md # Individual task spec
+ S01-SUMMARY.md # Slice summary
+ tasks/
+ T01-PLAN.md # Individual task spec
+ T01-SUMMARY.md # Task completion summary
```
-You never need to edit these files. GSD manages them. But you can read them to understand progress.
+State is derived from files on disk — checkboxes in ROADMAP.md and PLAN.md are the source of truth for completion. You never need to edit these files. GSD manages them. But you can read them to understand progress.
+
+
+| Flag | Description |
+|------|-------------|
+| `--output-format ` | `text` (default), `json` (structured result at exit), `stream-json` (JSONL events) |
+| `--json` | Alias for `--output-format stream-json` — JSONL event stream to stdout |
+| `--bare` | Skip CLAUDE.md, AGENTS.md, user settings, user skills. Use for CI/ecosystem runs. |
+| `--resume ` | Resume a prior headless session by its session ID |
+| `--timeout N` | Overall timeout in ms (default: 300000, use 0 to disable) |
+| `--model ID` | Override LLM model |
+| `--supervised` | Forward interactive UI requests to orchestrator via stdout/stdin |
+| `--response-timeout N` | Timeout (ms) for orchestrator response in supervised mode (default: 30000) |
+| `--answers ` | Pre-supply answers and secrets from JSON file |
+| `--events ` | Filter JSONL to specific event types (comma-separated, implies `--json`) |
+| `--verbose` | Show tool calls in progress output |
+| `--context ` | Spec file path for `new-milestone` (use `-` for stdin) |
+| `--context-text ` | Inline spec text for `new-milestone` |
+| `--auto` | Chain into auto-mode after `new-milestone` |
+
+
+
+Pre-supply answers and secrets for fully autonomous runs:
+
+```bash
+gsd headless --answers answers.json --output-format json auto 2>/dev/null
+```
+
+```json
+{
+ "questions": { "question_id": "selected_option" },
+ "secrets": { "API_KEY": "sk-..." },
+ "defaults": { "strategy": "first_option" }
+}
+```
+
+- **questions** — question ID to answer (string for single-select, string[] for multi-select)
+- **secrets** — env var to value, injected into child process environment
+- **defaults.strategy** — `"first_option"` (default) or `"cancel"` for unmatched questions
+
+See `references/answer-injection.md` for the full mechanism.
+
+
+
+For real-time monitoring, use JSONL event streaming:
+
+```bash
+gsd headless --json auto 2>/dev/null | while read -r line; do
+ TYPE=$(echo "$line" | jq -r '.type')
+ case "$TYPE" in
+ tool_execution_start) echo "Tool: $(echo "$line" | jq -r '.toolName')" ;;
+ extension_ui_request) echo "GSD: $(echo "$line" | jq -r '.message // .title // empty')" ;;
+ agent_end) echo "Session ended" ;;
+ esac
+done
+```
+
+Filter to specific events: `--events agent_end,execution_complete,extension_ui_request`
+
+Available types: `agent_start`, `agent_end`, `tool_execution_start`, `tool_execution_end`,
+`tool_execution_update`, `extension_ui_request`, `message_start`, `message_end`,
+`message_update`, `turn_start`, `turn_end`, `cost_update`, `execution_complete`.
+
+
+
+| Command | Purpose |
+|---------|---------|
+| `auto` | Run all queued units until milestone complete or blocked (default) |
+| `next` | Run exactly one unit, then exit |
+| `query` | Instant JSON snapshot — state, next dispatch, costs (no LLM, ~50ms) |
+| `new-milestone` | Create milestone from spec file |
+| `dispatch ` | Force specific phase (research, plan, execute, complete, reassess, uat, replan) |
+| `stop` / `pause` | Control auto-mode |
+| `steer ` | Hard-steer plan mid-execution |
+| `skip` / `undo` | Unit control |
+| `queue` | Queue/reorder milestones |
+| `history` | View execution history |
+| `doctor` | Health check + auto-fix |
+| `knowledge ` | Add persistent project knowledge |
+
+See `references/commands.md` for the complete reference.
+
diff --git a/packages/daemon/src/config.ts b/packages/daemon/src/config.ts
index 5e543f3fa..8b2c6063a 100644
--- a/packages/daemon/src/config.ts
+++ b/packages/daemon/src/config.ts
@@ -56,6 +56,7 @@ export function validateConfig(raw: unknown): DaemonConfig {
token: typeof d['token'] === 'string' ? d['token'] : '',
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'] } : {}),
};
}
diff --git a/packages/daemon/src/event-formatter.test.ts b/packages/daemon/src/event-formatter.test.ts
new file mode 100644
index 000000000..dead1e385
--- /dev/null
+++ b/packages/daemon/src/event-formatter.test.ts
@@ -0,0 +1,402 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { EmbedBuilder, ActionRowBuilder, ButtonBuilder } from 'discord.js';
+import type { SdkAgentEvent } from '@gsd-build/rpc-client';
+import type { PendingBlocker, FormattedEvent } from './types.js';
+import type { RpcExtensionUIRequest } from '@gsd-build/rpc-client';
+import {
+ formatToolStart,
+ formatToolEnd,
+ formatMessage,
+ formatBlocker,
+ formatCompletion,
+ formatError,
+ formatCostUpdate,
+ formatSessionStarted,
+ formatTaskTransition,
+ formatGenericEvent,
+ formatEvent,
+} from './event-formatter.js';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function embedColor(fe: FormattedEvent): number | null {
+ return fe.embed?.data.color ?? null;
+}
+
+function embedTitle(fe: FormattedEvent): string | undefined {
+ return fe.embed?.data.title;
+}
+
+function embedDescription(fe: FormattedEvent): string | undefined {
+ return fe.embed?.data.description;
+}
+
+// ---------------------------------------------------------------------------
+// formatToolStart
+// ---------------------------------------------------------------------------
+
+describe('formatToolStart', () => {
+ it('produces grey embed with tool name', () => {
+ const result = formatToolStart({ type: 'tool_execution_start', name: 'read_file' });
+ assert.ok(result.content.includes('read_file'));
+ assert.equal(embedColor(result), 0x95a5a6); // grey
+ assert.ok(embedTitle(result)?.includes('read_file'));
+ });
+
+ it('handles missing name gracefully', () => {
+ const result = formatToolStart({ type: 'tool_execution_start' });
+ assert.ok(result.content.includes('unknown'));
+ });
+
+ it('includes input in description when present', () => {
+ const result = formatToolStart({ type: 'tool_execution_start', name: 'bash', input: 'ls -la' });
+ assert.ok(embedDescription(result)?.includes('ls -la'));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatToolEnd
+// ---------------------------------------------------------------------------
+
+describe('formatToolEnd', () => {
+ it('shows success icon for normal completion', () => {
+ const result = formatToolEnd({ type: 'tool_execution_end', name: 'read_file', output: 'done' });
+ assert.ok(result.content.includes('✅'));
+ assert.equal(embedColor(result), 0x95a5a6); // grey
+ });
+
+ it('shows error icon and red color for errored tool', () => {
+ const result = formatToolEnd({ type: 'tool_execution_end', name: 'bash', isError: true });
+ assert.ok(result.content.includes('❌'));
+ assert.equal(embedColor(result), 0xe74c3c); // red
+ });
+
+ it('includes duration when present', () => {
+ const result = formatToolEnd({ type: 'tool_execution_end', name: 'bash', duration: 3500 });
+ assert.ok(result.embed?.data.footer?.text?.includes('3.5s'));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatMessage
+// ---------------------------------------------------------------------------
+
+describe('formatMessage', () => {
+ it('extracts text from content blocks', () => {
+ const result = formatMessage({
+ type: 'message',
+ content: [{ type: 'text', text: 'Hello world' }],
+ });
+ assert.ok(embedDescription(result)?.includes('Hello world'));
+ assert.equal(embedColor(result), 0x3498db); // blue
+ });
+
+ it('falls back to message field when content is a string', () => {
+ const result = formatMessage({ type: 'message', message: 'plain text' });
+ assert.ok(embedDescription(result)?.includes('plain text'));
+ });
+
+ it('handles empty content blocks', () => {
+ const result = formatMessage({ type: 'message', content: [] });
+ assert.ok(result.content.includes('empty message'));
+ assert.equal(result.embed, undefined);
+ });
+
+ it('handles null content gracefully', () => {
+ const result = formatMessage({ type: 'message' });
+ assert.ok(result.content.includes('empty message'));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatBlocker — select
+// ---------------------------------------------------------------------------
+
+describe('formatBlocker', () => {
+ it('produces ActionRow with numbered buttons for select', () => {
+ const blocker: PendingBlocker = {
+ id: 'req-1',
+ method: 'select',
+ message: 'Choose an option',
+ event: {
+ type: 'extension_ui_request',
+ id: 'req-1',
+ method: 'select',
+ title: 'Choose',
+ options: ['Option A', 'Option B', 'Option C'],
+ },
+ };
+
+ const result = formatBlocker(blocker, '12345');
+ assert.ok(result.content.includes('<@12345>'));
+ assert.equal(embedColor(result), 0xf1c40f); // yellow
+ assert.ok(result.components);
+ assert.ok(result.components!.length > 0);
+
+ // Check buttons
+ const row = result.components![0];
+ const buttons = row.components;
+ assert.equal(buttons.length, 3);
+ });
+
+ it('handles empty options array for select', () => {
+ const blocker: PendingBlocker = {
+ id: 'req-2',
+ method: 'select',
+ message: 'Pick one',
+ event: {
+ type: 'extension_ui_request',
+ id: 'req-2',
+ method: 'select',
+ title: 'Pick',
+ options: [],
+ },
+ };
+
+ const result = formatBlocker(blocker, '12345');
+ // No components when no options
+ assert.equal(result.components, undefined);
+ // Embed should show 'No options'
+ const fields = result.embed?.data.fields;
+ assert.ok(fields?.some((f) => f.value.includes('No options')));
+ });
+
+ it('produces Yes/No buttons for confirm', () => {
+ const blocker: PendingBlocker = {
+ id: 'req-3',
+ method: 'confirm',
+ message: 'Are you sure?',
+ event: {
+ type: 'extension_ui_request',
+ id: 'req-3',
+ method: 'confirm',
+ title: 'Confirm',
+ message: 'This will delete everything',
+ },
+ };
+
+ const result = formatBlocker(blocker, '99999');
+ assert.ok(result.components);
+ assert.equal(result.components!.length, 1);
+ const buttons = result.components![0].components;
+ assert.equal(buttons.length, 2);
+ });
+
+ it('produces text instructions for input method', () => {
+ const blocker: PendingBlocker = {
+ id: 'req-4',
+ method: 'input',
+ message: 'Enter your name',
+ event: {
+ type: 'extension_ui_request',
+ id: 'req-4',
+ method: 'input',
+ title: 'Name',
+ placeholder: 'John Doe',
+ },
+ };
+
+ const result = formatBlocker(blocker, '12345');
+ // No interactive buttons for input — text instructions only
+ assert.equal(result.components, undefined);
+ const fields = result.embed?.data.fields;
+ assert.ok(fields?.some((f) => f.value.includes('Reply in this channel')));
+ });
+
+ it('produces text instructions for editor method', () => {
+ const blocker: PendingBlocker = {
+ id: 'req-5',
+ method: 'editor',
+ message: 'Edit the config',
+ event: {
+ type: 'extension_ui_request',
+ id: 'req-5',
+ method: 'editor',
+ title: 'Config',
+ prefill: 'key: value',
+ },
+ };
+
+ const result = formatBlocker(blocker, '12345');
+ assert.equal(result.components, undefined);
+ const fields = result.embed?.data.fields;
+ assert.ok(fields?.some((f) => f.value.includes('Reply in this channel')));
+ assert.ok(fields?.some((f) => f.value.includes('key: value')));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatCompletion
+// ---------------------------------------------------------------------------
+
+describe('formatCompletion', () => {
+ it('shows green for completed', () => {
+ const result = formatCompletion({ type: 'execution_complete', status: 'completed' });
+ assert.equal(embedColor(result), 0x2ecc71); // green
+ assert.ok(result.content.includes('🏁'));
+ });
+
+ it('shows red for error status', () => {
+ const result = formatCompletion({
+ type: 'execution_complete',
+ status: 'error',
+ reason: 'Out of tokens',
+ });
+ assert.equal(embedColor(result), 0xe74c3c); // red
+ assert.ok(embedDescription(result)?.includes('Out of tokens'));
+ });
+
+ it('includes stats when present', () => {
+ const result = formatCompletion({
+ type: 'execution_complete',
+ status: 'completed',
+ stats: { cost: 0.42, tokens: { total: 10000 } },
+ });
+ const fields = result.embed?.data.fields;
+ assert.ok(fields?.some((f) => f.value.includes('$0.42')));
+ assert.ok(fields?.some((f) => f.value.includes('10,000')));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatError
+// ---------------------------------------------------------------------------
+
+describe('formatError', () => {
+ it('includes session ID and error message', () => {
+ const result = formatError('sess-abc', 'Connection refused');
+ assert.equal(embedColor(result), 0xe74c3c); // red
+ assert.ok(embedDescription(result)?.includes('Connection refused'));
+ assert.ok(result.embed?.data.footer?.text?.includes('sess-abc'));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatCostUpdate
+// ---------------------------------------------------------------------------
+
+describe('formatCostUpdate', () => {
+ it('formats cumulative cost', () => {
+ const result = formatCostUpdate({
+ type: 'cost_update',
+ cumulativeCost: 1.23,
+ tokens: { input: 5000, output: 2000 },
+ });
+ assert.ok(result.content.includes('$1.23'));
+ assert.equal(embedColor(result), 0x3498db); // blue
+ });
+
+ it('handles zero cost', () => {
+ const result = formatCostUpdate({
+ type: 'cost_update',
+ cumulativeCost: 0,
+ tokens: { input: 0, output: 0 },
+ });
+ assert.ok(result.content.includes('$0.0000'));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatSessionStarted
+// ---------------------------------------------------------------------------
+
+describe('formatSessionStarted', () => {
+ it('includes project name', () => {
+ const result = formatSessionStarted('my-project');
+ assert.ok(result.content.includes('my-project'));
+ assert.ok(embedDescription(result)?.includes('my-project'));
+ assert.equal(embedColor(result), 0x3498db); // blue
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatTaskTransition
+// ---------------------------------------------------------------------------
+
+describe('formatTaskTransition', () => {
+ it('shows complete icon for completed tasks', () => {
+ const result = formatTaskTransition({
+ type: 'task_transition',
+ taskId: 'T01',
+ sliceId: 'S01',
+ status: 'complete',
+ });
+ assert.ok(result.content.includes('✅'));
+ assert.equal(embedColor(result), 0x2ecc71); // green
+ });
+
+ it('shows error icon for errored tasks', () => {
+ const result = formatTaskTransition({
+ type: 'task_transition',
+ taskId: 'T02',
+ status: 'error',
+ });
+ assert.ok(result.content.includes('❌'));
+ assert.equal(embedColor(result), 0xe74c3c); // red
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatGenericEvent
+// ---------------------------------------------------------------------------
+
+describe('formatGenericEvent', () => {
+ it('renders unknown event type as grey embed', () => {
+ const result = formatGenericEvent({ type: 'some_custom_event', data: 'hello' });
+ assert.equal(embedColor(result), 0x95a5a6); // grey
+ assert.ok(embedTitle(result)?.includes('some_custom_event'));
+ });
+
+ it('handles events with no extra fields', () => {
+ const result = formatGenericEvent({ type: 'bare_event' });
+ assert.ok(result.content.includes('bare_event'));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatEvent — dispatch
+// ---------------------------------------------------------------------------
+
+describe('formatEvent', () => {
+ it('dispatches tool_execution_start', () => {
+ const result = formatEvent({ type: 'tool_execution_start', name: 'read' });
+ assert.ok(result.content.includes('🔧'));
+ });
+
+ it('dispatches execution_complete', () => {
+ const result = formatEvent({ type: 'execution_complete', status: 'completed' });
+ assert.ok(result.content.includes('🏁'));
+ });
+
+ it('falls back to generic for unknown types', () => {
+ const result = formatEvent({ type: 'totally_unknown' });
+ assert.ok(result.content.includes('📡'));
+ });
+
+ it('dispatches cost_update', () => {
+ const result = formatEvent({ type: 'cost_update', cumulativeCost: 0.5 });
+ assert.ok(result.content.includes('💰'));
+ });
+
+ it('dispatches message types', () => {
+ for (const type of ['message_start', 'message_end', 'message']) {
+ const result = formatEvent({ type, message: 'hi' });
+ assert.ok(result.content.includes('💬'), `Failed for type: ${type}`);
+ }
+ });
+
+ // Negative: missing type field
+ it('handles event with missing type gracefully', () => {
+ const result = formatEvent({} as SdkAgentEvent);
+ assert.ok(result.content); // should not throw
+ });
+
+ // Negative: null fields
+ it('handles event with null fields gracefully', () => {
+ const result = formatEvent({ type: 'tool_execution_start', name: null } as unknown as SdkAgentEvent);
+ assert.ok(result.content);
+ });
+});
diff --git a/packages/daemon/src/event-formatter.ts b/packages/daemon/src/event-formatter.ts
new file mode 100644
index 000000000..ccd2a7733
--- /dev/null
+++ b/packages/daemon/src/event-formatter.ts
@@ -0,0 +1,386 @@
+/**
+ * event-formatter.ts — Pure functions mapping RPC event types to Discord embeds.
+ *
+ * Each formatter returns a FormattedEvent (content string + optional EmbedBuilder +
+ * optional ActionRow components). Distinct embed colors per category:
+ * green = success / completion
+ * red = error
+ * yellow = blocker (needs attention)
+ * blue = info / session lifecycle
+ * grey = tool / generic
+ */
+
+import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';
+import type { SdkAgentEvent } from '@gsd-build/rpc-client';
+import type { RpcExtensionUIRequest } from '@gsd-build/rpc-client';
+import type { FormattedEvent, PendingBlocker } from './types.js';
+
+// ---------------------------------------------------------------------------
+// Color palette
+// ---------------------------------------------------------------------------
+
+const COLOR = {
+ success: 0x2ecc71, // green
+ error: 0xe74c3c, // red
+ blocker: 0xf1c40f, // yellow
+ info: 0x3498db, // blue
+ tool: 0x95a5a6, // grey
+} as const;
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Truncate a string to maxLen, appending ellipsis if truncated. */
+function truncate(s: string, maxLen: number): string {
+ if (s.length <= maxLen) return s;
+ return s.slice(0, maxLen - 1) + '…';
+}
+
+/** Safe string extraction from an unknown field. */
+function str(value: unknown, fallback = ''): string {
+ if (typeof value === 'string') return value;
+ if (value == null) return fallback;
+ return String(value);
+}
+
+/** Safe number extraction. */
+function num(value: unknown, fallback = 0): number {
+ if (typeof value === 'number' && !Number.isNaN(value)) return value;
+ return fallback;
+}
+
+/** Format a cost value to a readable string. */
+function formatCost(cost: number): string {
+ if (cost < 0.01) return `$${cost.toFixed(4)}`;
+ return `$${cost.toFixed(2)}`;
+}
+
+// ---------------------------------------------------------------------------
+// Formatters
+// ---------------------------------------------------------------------------
+
+export function formatToolStart(event: SdkAgentEvent): FormattedEvent {
+ const toolName = str(event.name || event.toolName, 'unknown');
+ const embed = new EmbedBuilder()
+ .setColor(COLOR.tool)
+ .setTitle(`🔧 ${truncate(toolName, 60)}`)
+ .setTimestamp();
+
+ const input = str(event.input || event.args);
+ if (input) {
+ embed.setDescription(`\`\`\`\n${truncate(input, 300)}\n\`\`\``);
+ }
+
+ return { content: `🔧 Tool: ${toolName}`, embed };
+}
+
+export function formatToolEnd(event: SdkAgentEvent): FormattedEvent {
+ const toolName = str(event.name || event.toolName, 'unknown');
+ const isError = event.isError === true || event.error != null;
+ const color = isError ? COLOR.error : COLOR.tool;
+ const icon = isError ? '❌' : '✅';
+
+ const embed = new EmbedBuilder()
+ .setColor(color)
+ .setTitle(`${icon} ${truncate(toolName, 60)}`)
+ .setTimestamp();
+
+ const output = str(event.output || event.result);
+ if (output) {
+ embed.setDescription(`\`\`\`\n${truncate(output, 300)}\n\`\`\``);
+ }
+
+ const duration = num(event.duration || event.durationMs);
+ if (duration > 0) {
+ embed.setFooter({ text: `${(duration / 1000).toFixed(1)}s` });
+ }
+
+ return { content: `${icon} Tool done: ${toolName}`, embed };
+}
+
+export function formatMessage(event: SdkAgentEvent): FormattedEvent {
+ // Extract text from content blocks or message field
+ let text = '';
+ if (Array.isArray(event.content)) {
+ const blocks = event.content as Array<{ type?: string; text?: string }>;
+ text = blocks
+ .filter((b) => b.type === 'text' && typeof b.text === 'string')
+ .map((b) => b.text!)
+ .join('\n');
+ } else {
+ text = str(event.message || event.text || event.content);
+ }
+
+ if (!text) {
+ return { content: '💬 (empty message)' };
+ }
+
+ const embed = new EmbedBuilder()
+ .setColor(COLOR.info)
+ .setDescription(truncate(text, 2000))
+ .setTimestamp();
+
+ const role = str(event.role);
+ if (role) {
+ embed.setAuthor({ name: role });
+ }
+
+ return { content: `💬 ${truncate(text, 200)}`, embed };
+}
+
+/**
+ * Format a blocker (extension_ui_request needing user response).
+ * Produces an embed with @mention and interactive buttons for select/confirm,
+ * or text instructions for input/editor.
+ */
+export function formatBlocker(
+ blocker: PendingBlocker,
+ ownerId: string,
+): FormattedEvent {
+ const mention = `<@${ownerId}>`;
+ const embed = new EmbedBuilder()
+ .setColor(COLOR.blocker)
+ .setTitle('⚠️ Blocker — Response Needed')
+ .setDescription(truncate(blocker.message, 2000))
+ .setTimestamp();
+
+ const components: ActionRowBuilder[] = [];
+
+ switch (blocker.method) {
+ case 'select': {
+ const evt = blocker.event as { options?: string[] };
+ const options = Array.isArray(evt.options) ? evt.options : [];
+
+ if (options.length > 0) {
+ // Discord ActionRow max 5 buttons, so chunk
+ const chunks = chunkArray(options.slice(0, 25), 5);
+ for (const chunk of chunks) {
+ const row = new ActionRowBuilder();
+ chunk.forEach((opt, i) => {
+ const globalIndex = options.indexOf(opt);
+ row.addComponents(
+ new ButtonBuilder()
+ .setCustomId(`blocker:${blocker.id}:select:${globalIndex}`)
+ .setLabel(truncate(`${globalIndex + 1}. ${opt}`, 80))
+ .setStyle(ButtonStyle.Primary),
+ );
+ });
+ components.push(row);
+ }
+ }
+
+ embed.addFields({
+ name: 'Options',
+ value: options.map((o, i) => `**${i + 1}.** ${truncate(o, 100)}`).join('\n') || 'No options',
+ });
+ break;
+ }
+
+ case 'confirm': {
+ const row = new ActionRowBuilder().addComponents(
+ new ButtonBuilder()
+ .setCustomId(`blocker:${blocker.id}:confirm:true`)
+ .setLabel('Yes')
+ .setStyle(ButtonStyle.Success),
+ new ButtonBuilder()
+ .setCustomId(`blocker:${blocker.id}:confirm:false`)
+ .setLabel('No')
+ .setStyle(ButtonStyle.Danger),
+ );
+ components.push(row);
+
+ const msg = str((blocker.event as { message?: string }).message);
+ if (msg) {
+ embed.addFields({ name: 'Details', value: truncate(msg, 1024) });
+ }
+ break;
+ }
+
+ case 'input': {
+ const placeholder = str((blocker.event as { placeholder?: string }).placeholder);
+ embed.addFields({
+ name: 'How to respond',
+ value: `Reply in this channel with your answer.${placeholder ? `\n*Hint: ${placeholder}*` : ''}`,
+ });
+ break;
+ }
+
+ case 'editor': {
+ const prefill = str((blocker.event as { prefill?: string }).prefill);
+ embed.addFields({
+ name: 'How to respond',
+ value: 'Reply in this channel with the full text.' +
+ (prefill ? `\n\nCurrent value:\n\`\`\`\n${truncate(prefill, 500)}\n\`\`\`` : ''),
+ });
+ break;
+ }
+
+ default: {
+ embed.addFields({
+ name: 'How to respond',
+ value: `Reply in this channel (method: ${blocker.method}).`,
+ });
+ break;
+ }
+ }
+
+ return {
+ content: `${mention} ⚠️ **Blocker** — ${truncate(blocker.message, 150)}`,
+ embed,
+ components: components.length > 0 ? components : undefined,
+ };
+}
+
+export function formatCompletion(event: SdkAgentEvent): FormattedEvent {
+ const status = str(event.status, 'completed');
+ const isError = status === 'error' || status === 'cancelled';
+ const color = isError ? COLOR.error : COLOR.success;
+ const icon = isError ? '⚠️' : '🏁';
+
+ const embed = new EmbedBuilder()
+ .setColor(color)
+ .setTitle(`${icon} Execution ${status}`)
+ .setTimestamp();
+
+ const reason = str(event.reason);
+ if (reason) {
+ embed.setDescription(truncate(reason, 2000));
+ }
+
+ // Include final stats if present
+ const stats = event.stats as { cost?: number; tokens?: { total?: number } } | undefined;
+ if (stats) {
+ const fields: string[] = [];
+ if (stats.cost != null) fields.push(`Cost: ${formatCost(num(stats.cost))}`);
+ if (stats.tokens?.total != null) fields.push(`Tokens: ${num(stats.tokens.total).toLocaleString()}`);
+ if (fields.length) embed.addFields({ name: 'Summary', value: fields.join(' · ') });
+ }
+
+ return { content: `${icon} Execution ${status}`, embed };
+}
+
+export function formatError(sessionId: string, error: string): FormattedEvent {
+ const embed = new EmbedBuilder()
+ .setColor(COLOR.error)
+ .setTitle('❌ Session Error')
+ .setDescription(`\`\`\`\n${truncate(error, 2000)}\n\`\`\``)
+ .setFooter({ text: `Session: ${sessionId}` })
+ .setTimestamp();
+
+ return { content: `❌ Error: ${truncate(error, 200)}`, embed };
+}
+
+export function formatCostUpdate(event: SdkAgentEvent): FormattedEvent {
+ const cost = num(event.cumulativeCost ?? event.totalCost);
+ const tokens = event.tokens as
+ | { input?: number; output?: number; cacheRead?: number; cacheWrite?: number }
+ | undefined;
+
+ const embed = new EmbedBuilder()
+ .setColor(COLOR.info)
+ .setTitle('💰 Cost Update')
+ .setTimestamp();
+
+ const fields: string[] = [`Total: ${formatCost(cost)}`];
+ if (tokens) {
+ const input = num(tokens.input);
+ const output = num(tokens.output);
+ if (input || output) {
+ fields.push(`Tokens: ${input.toLocaleString()} in / ${output.toLocaleString()} out`);
+ }
+ }
+ embed.setDescription(fields.join('\n'));
+
+ return { content: `💰 Cost: ${formatCost(cost)}`, embed };
+}
+
+export function formatSessionStarted(projectName: string): FormattedEvent {
+ const embed = new EmbedBuilder()
+ .setColor(COLOR.info)
+ .setTitle('🚀 Session Started')
+ .setDescription(`Project: **${truncate(projectName, 200)}**`)
+ .setTimestamp();
+
+ return { content: `🚀 Session started: ${projectName}`, embed };
+}
+
+export function formatTaskTransition(event: SdkAgentEvent): FormattedEvent {
+ const taskId = str(event.taskId || event.task);
+ const sliceId = str(event.sliceId || event.slice);
+ const status = str(event.status || event.state);
+ const icon = status === 'complete' ? '✅' : status === 'error' ? '❌' : '📋';
+
+ const embed = new EmbedBuilder()
+ .setColor(status === 'complete' ? COLOR.success : status === 'error' ? COLOR.error : COLOR.info)
+ .setTitle(`${icon} Task Transition`)
+ .setTimestamp();
+
+ const fields: string[] = [];
+ if (sliceId) fields.push(`Slice: ${sliceId}`);
+ if (taskId) fields.push(`Task: ${taskId}`);
+ if (status) fields.push(`Status: ${status}`);
+ embed.setDescription(fields.join('\n'));
+
+ return { content: `${icon} ${taskId || 'Task'} → ${status || 'unknown'}`, embed };
+}
+
+export function formatGenericEvent(event: SdkAgentEvent): FormattedEvent {
+ const type = str(event.type, 'unknown');
+ const embed = new EmbedBuilder()
+ .setColor(COLOR.tool)
+ .setTitle(`📡 ${truncate(type, 60)}`)
+ .setTimestamp();
+
+ // Include a JSON preview of the event, stripping the type field
+ const { type: _t, ...rest } = event;
+ const preview = JSON.stringify(rest);
+ if (preview.length > 2) { // more than '{}'
+ embed.setDescription(`\`\`\`json\n${truncate(preview, 1000)}\n\`\`\``);
+ }
+
+ return { content: `📡 Event: ${type}`, embed };
+}
+
+// ---------------------------------------------------------------------------
+// Dispatch — maps event type to the right formatter
+// ---------------------------------------------------------------------------
+
+/**
+ * Format any SdkAgentEvent for Discord. Falls back to formatGenericEvent
+ * for unknown types.
+ */
+export function formatEvent(event: SdkAgentEvent, ownerId?: string): FormattedEvent {
+ const type = str(event.type);
+
+ switch (type) {
+ case 'tool_execution_start':
+ return formatToolStart(event);
+ case 'tool_execution_end':
+ return formatToolEnd(event);
+ case 'message_start':
+ case 'message_end':
+ case 'message':
+ return formatMessage(event);
+ case 'execution_complete':
+ return formatCompletion(event);
+ case 'cost_update':
+ return formatCostUpdate(event);
+ case 'task_transition':
+ return formatTaskTransition(event);
+ default:
+ return formatGenericEvent(event);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Utility
+// ---------------------------------------------------------------------------
+
+function chunkArray(arr: T[], size: number): T[][] {
+ const chunks: T[][] = [];
+ for (let i = 0; i < arr.length; i += size) {
+ chunks.push(arr.slice(i, i + size));
+ }
+ return chunks;
+}
diff --git a/packages/daemon/src/types.ts b/packages/daemon/src/types.ts
index 16ed5ea16..4dc74291e 100644
--- a/packages/daemon/src/types.ts
+++ b/packages/daemon/src/types.ts
@@ -5,6 +5,14 @@ import type { RpcClient, SdkAgentEvent, RpcExtensionUIRequest } from '@gsd-build
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
+/**
+ * Per-channel verbosity for Discord event streaming.
+ * - 'default': tool calls, messages, transitions, blockers, errors, completions
+ * - 'verbose': everything including cost_update and status events
+ * - 'quiet': only blockers, errors, completions
+ */
+export type VerbosityLevel = 'default' | 'verbose' | 'quiet';
+
/**
* A single structured log entry written as JSON-lines.
*/
@@ -24,6 +32,8 @@ export interface DaemonConfig {
token: string;
guild_id: string;
owner_id: string;
+ /** When true, DM the owner on blocker events in addition to channel messages */
+ dm_on_blocker?: boolean;
};
projects: {
scan_roots: string[];
@@ -157,6 +167,20 @@ export interface StartSessionOptions {
cliPath?: string;
}
+// ---------------------------------------------------------------------------
+// Formatted Event — output of event-formatter.ts
+// ---------------------------------------------------------------------------
+
+/**
+ * Formatted Discord message payload for a GSD event.
+ * content is the plain-text fallback; embeds and components are optional.
+ */
+export interface FormattedEvent {
+ content: string;
+ embed?: import('discord.js').EmbedBuilder;
+ components?: import('discord.js').ActionRowBuilder[];
+}
+
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
diff --git a/packages/daemon/src/verbosity.test.ts b/packages/daemon/src/verbosity.test.ts
new file mode 100644
index 000000000..42c61e9b6
--- /dev/null
+++ b/packages/daemon/src/verbosity.test.ts
@@ -0,0 +1,171 @@
+import { describe, it, beforeEach } from 'node:test';
+import assert from 'node:assert/strict';
+import { VerbosityManager, shouldShowAtLevel } from './verbosity.js';
+
+// ---------------------------------------------------------------------------
+// VerbosityManager
+// ---------------------------------------------------------------------------
+
+describe('VerbosityManager', () => {
+ let vm: VerbosityManager;
+
+ beforeEach(() => {
+ vm = new VerbosityManager();
+ });
+
+ it('returns default level for unknown channel', () => {
+ assert.equal(vm.getLevel('chan-1'), 'default');
+ });
+
+ it('set/get round-trips', () => {
+ vm.setLevel('chan-1', 'quiet');
+ assert.equal(vm.getLevel('chan-1'), 'quiet');
+ vm.setLevel('chan-1', 'verbose');
+ assert.equal(vm.getLevel('chan-1'), 'verbose');
+ });
+
+ it('different channels are independent', () => {
+ vm.setLevel('chan-a', 'quiet');
+ vm.setLevel('chan-b', 'verbose');
+ assert.equal(vm.getLevel('chan-a'), 'quiet');
+ assert.equal(vm.getLevel('chan-b'), 'verbose');
+ assert.equal(vm.getLevel('chan-c'), 'default');
+ });
+
+ it('shouldShow delegates to the level-based filter', () => {
+ vm.setLevel('chan-q', 'quiet');
+ assert.equal(vm.shouldShow('chan-q', 'tool_execution_start'), false);
+ assert.equal(vm.shouldShow('chan-q', 'extension_ui_request'), true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// shouldShowAtLevel — quiet
+// ---------------------------------------------------------------------------
+
+describe('shouldShowAtLevel — quiet', () => {
+ const level = 'quiet' as const;
+
+ it('shows blockers', () => {
+ assert.equal(shouldShowAtLevel(level, 'extension_ui_request'), true);
+ });
+
+ it('shows execution_complete', () => {
+ assert.equal(shouldShowAtLevel(level, 'execution_complete'), true);
+ });
+
+ it('shows error', () => {
+ assert.equal(shouldShowAtLevel(level, 'error'), true);
+ });
+
+ it('shows session_error', () => {
+ assert.equal(shouldShowAtLevel(level, 'session_error'), true);
+ });
+
+ it('hides tool calls', () => {
+ assert.equal(shouldShowAtLevel(level, 'tool_execution_start'), false);
+ assert.equal(shouldShowAtLevel(level, 'tool_execution_end'), false);
+ });
+
+ it('hides messages', () => {
+ assert.equal(shouldShowAtLevel(level, 'message_start'), false);
+ assert.equal(shouldShowAtLevel(level, 'message'), false);
+ });
+
+ it('hides cost_update', () => {
+ assert.equal(shouldShowAtLevel(level, 'cost_update'), false);
+ });
+
+ it('hides task_transition', () => {
+ assert.equal(shouldShowAtLevel(level, 'task_transition'), false);
+ });
+
+ it('hides unknown events', () => {
+ assert.equal(shouldShowAtLevel(level, 'totally_random'), false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// shouldShowAtLevel — default
+// ---------------------------------------------------------------------------
+
+describe('shouldShowAtLevel — default', () => {
+ const level = 'default' as const;
+
+ it('shows blockers', () => {
+ assert.equal(shouldShowAtLevel(level, 'extension_ui_request'), true);
+ });
+
+ it('shows execution_complete', () => {
+ assert.equal(shouldShowAtLevel(level, 'execution_complete'), true);
+ });
+
+ it('shows error', () => {
+ assert.equal(shouldShowAtLevel(level, 'error'), true);
+ });
+
+ it('shows tool calls', () => {
+ assert.equal(shouldShowAtLevel(level, 'tool_execution_start'), true);
+ assert.equal(shouldShowAtLevel(level, 'tool_execution_end'), true);
+ });
+
+ it('shows messages', () => {
+ assert.equal(shouldShowAtLevel(level, 'message_start'), true);
+ assert.equal(shouldShowAtLevel(level, 'message_end'), true);
+ assert.equal(shouldShowAtLevel(level, 'message'), true);
+ });
+
+ it('shows task_transition', () => {
+ assert.equal(shouldShowAtLevel(level, 'task_transition'), true);
+ });
+
+ it('shows session_started', () => {
+ assert.equal(shouldShowAtLevel(level, 'session_started'), true);
+ });
+
+ it('hides cost_update', () => {
+ assert.equal(shouldShowAtLevel(level, 'cost_update'), false);
+ });
+
+ it('hides status events', () => {
+ assert.equal(shouldShowAtLevel(level, 'state_update'), false);
+ assert.equal(shouldShowAtLevel(level, 'status'), false);
+ });
+
+ it('hides unknown events', () => {
+ assert.equal(shouldShowAtLevel(level, 'something_weird'), false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// shouldShowAtLevel — verbose
+// ---------------------------------------------------------------------------
+
+describe('shouldShowAtLevel — verbose', () => {
+ const level = 'verbose' as const;
+
+ it('shows everything that quiet/default show', () => {
+ const events = [
+ 'extension_ui_request', 'execution_complete', 'error', 'session_error',
+ 'tool_execution_start', 'tool_execution_end', 'message_start', 'message_end',
+ 'message', 'task_transition', 'session_started',
+ ];
+ for (const e of events) {
+ assert.equal(shouldShowAtLevel(level, e), true, `Expected verbose to show ${e}`);
+ }
+ });
+
+ it('shows cost_update', () => {
+ assert.equal(shouldShowAtLevel(level, 'cost_update'), true);
+ });
+
+ it('shows status events', () => {
+ assert.equal(shouldShowAtLevel(level, 'state_update'), true);
+ assert.equal(shouldShowAtLevel(level, 'status'), true);
+ assert.equal(shouldShowAtLevel(level, 'set_status'), true);
+ });
+
+ it('shows unknown/arbitrary events', () => {
+ assert.equal(shouldShowAtLevel(level, 'something_arbitrary'), true);
+ });
+});
diff --git a/packages/daemon/src/verbosity.ts b/packages/daemon/src/verbosity.ts
new file mode 100644
index 000000000..e40b11c87
--- /dev/null
+++ b/packages/daemon/src/verbosity.ts
@@ -0,0 +1,101 @@
+/**
+ * verbosity.ts — Per-channel verbosity filter for Discord event streaming.
+ *
+ * Controls which RPC event types reach each Discord channel.
+ * Three levels:
+ * - 'quiet': blockers, errors, completions only
+ * - 'default': tool calls, messages, transitions, blockers, errors, completions
+ * - 'verbose': everything (adds cost_update, status, generic events)
+ */
+
+import type { VerbosityLevel } from './types.js';
+
+// ---------------------------------------------------------------------------
+// Event classification
+// ---------------------------------------------------------------------------
+
+/** Event types that are always shown (even in quiet mode). */
+const ALWAYS_SHOWN: ReadonlySet = new Set([
+ 'extension_ui_request', // blockers
+ 'execution_complete',
+ 'error',
+ 'session_error',
+]);
+
+/** Event types shown at default level and above. */
+const DEFAULT_SHOWN: ReadonlySet = new Set([
+ 'tool_execution_start',
+ 'tool_execution_end',
+ 'message_start',
+ 'message_end',
+ 'message',
+ 'task_transition',
+ 'session_started',
+]);
+
+/** Event types shown only at verbose level. */
+const VERBOSE_ONLY: ReadonlySet = new Set([
+ 'cost_update',
+ 'state_update',
+ 'status',
+ 'set_status',
+ 'set_widget',
+ 'set_title',
+]);
+
+// ---------------------------------------------------------------------------
+// VerbosityManager
+// ---------------------------------------------------------------------------
+
+export class VerbosityManager {
+ private levels: Map = new Map();
+
+ /** Get the verbosity level for a channel. Defaults to 'default'. */
+ getLevel(channelId: string): VerbosityLevel {
+ return this.levels.get(channelId) ?? 'default';
+ }
+
+ /** Set the verbosity level for a channel. */
+ setLevel(channelId: string, level: VerbosityLevel): void {
+ this.levels.set(channelId, level);
+ }
+
+ /**
+ * Determine whether an event of the given type should be shown
+ * in the specified channel.
+ */
+ shouldShow(channelId: string, eventType: string): boolean {
+ const level = this.getLevel(channelId);
+ return shouldShowAtLevel(level, eventType);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Pure filter — exported for direct use and testability
+// ---------------------------------------------------------------------------
+
+/**
+ * Pure predicate: should an event of this type be shown at the given verbosity level?
+ */
+export function shouldShowAtLevel(level: VerbosityLevel, eventType: string): boolean {
+ // Always-shown events pass through regardless of level
+ if (ALWAYS_SHOWN.has(eventType)) return true;
+
+ switch (level) {
+ case 'quiet':
+ // Quiet only shows ALWAYS_SHOWN events
+ return false;
+
+ case 'default':
+ // Default shows ALWAYS_SHOWN + DEFAULT_SHOWN
+ return DEFAULT_SHOWN.has(eventType);
+
+ case 'verbose':
+ // Verbose shows everything
+ return true;
+
+ default:
+ // Unknown level → treat as default
+ return DEFAULT_SHOWN.has(eventType);
+ }
+}