feat: add desktop notifier

This commit is contained in:
insleker 2026-04-05 16:31:33 +08:00
parent d691ecec15
commit c1c6f4fba5
8 changed files with 365 additions and 28 deletions

View File

@ -5,14 +5,14 @@ GitHub issue thread assistant with an Agent Orchestrator style YAML config.
It polls configured GitHub repositories with `gh`, reads issue threads, and
uses CLI responders such as `codex`, `opencode`, or `copilot-cli` to decide
when to reply. It supports multi-repo config, responder fallback chains, and
optional Discord webhook notifications.
optional Discord webhook and desktop notifications.
## Highlights
- Poll GitHub issues across multiple repositories
- Trigger replies from mentions or meaningful thread updates
- Run planning and bug-verification flows in isolated worktrees
- Send optional Discord notifications for assistant events
- Send optional Discord or desktop notifications for assistant events
## Requirements

View File

@ -37,6 +37,10 @@ defaults:
events: ["mention_detected", "reply_posted", "responder_failed"]
webhookUrl: ${CWS_DISCORD_WEBHOOK}
username: code-work-spawner
# Optional desktop notifications for local polling runs.
- type: desktop
enabled: false
events: ["mention_detected", "responder_failed"]
projects:
code-work-spawner:

View File

@ -624,6 +624,9 @@ ${_commentBlock(defaultIssueAssistantPrompt, indent: ' # ')}
# events: ["reply_posted", "responder_failed"]
# webhookUrl: \${CWS_DISCORD_WEBHOOK}
# username: code-work-spawner
# - type: desktop
# enabled: true
# events: ["mention_detected", "responder_failed"]
${_YamlEmitter().write({'projects': projectsSection})}

View File

@ -177,7 +177,7 @@ class NotificationConfigDocument {
@JsonEnum(fieldRename: FieldRename.snake)
enum AssistantCapability { planning, bugVerification }
enum NotificationType { discordWebhook }
enum NotificationType { discordWebhook, desktop }
@JsonEnum(fieldRename: FieldRename.snake)
enum NotificationEvent {

View File

@ -4,6 +4,11 @@ import 'dart:io';
import 'config.dart';
typedef NotificationLogger = void Function(String message);
typedef DesktopNotificationCommandRunner =
Future<ProcessResult> Function({
required String executable,
required List<String> arguments,
});
class NotificationContext {
const NotificationContext({
@ -40,6 +45,18 @@ abstract interface class Notifier {
});
}
enum DesktopNotificationPlatform { linux, macos, windows, unsupported }
class DesktopNotificationCommand {
const DesktopNotificationCommand({
required this.executable,
required this.arguments,
});
final String executable;
final List<String> arguments;
}
class NotifierRegistry {
NotifierRegistry({required this.notifiersByEvent, required this.log});
@ -58,6 +75,7 @@ class NotifierRegistry {
final notifier = switch (config.type) {
NotificationType.discordWebhook => DiscordWebhookNotifier(config),
NotificationType.desktop => DesktopNotifier(config),
};
for (final event in config.events) {
notifiersByEvent.putIfAbsent(event, () => <Notifier>[]).add(notifier);
@ -109,7 +127,10 @@ class DiscordWebhookNotifier implements Notifier {
request.headers.contentType = ContentType.json;
request.write(
jsonEncode({
'content': _formatDiscordMessage(event: event, context: context),
'content': formatDiscordNotificationMessage(
event: event,
context: context,
),
if (config.username != null && config.username!.isNotEmpty)
'username': config.username,
if (config.avatarUrl != null && config.avatarUrl!.isNotEmpty)
@ -126,32 +147,188 @@ class DiscordWebhookNotifier implements Notifier {
);
}
}
}
String _formatDiscordMessage({
class DesktopNotifier implements Notifier {
DesktopNotifier(
this.config, {
DesktopNotificationPlatform? platform,
DesktopNotificationCommandRunner? commandRunner,
}) : _platform = platform ?? detectDesktopNotificationPlatform(),
_commandRunner = commandRunner ?? _runDesktopNotificationCommand;
final NotificationConfig config;
final DesktopNotificationPlatform _platform;
final DesktopNotificationCommandRunner _commandRunner;
@override
Future<void> notify({
required NotificationEvent event,
required NotificationContext context,
}) async {
final command = _buildCommand(event: event, context: context);
final result = await _commandRunner(
executable: command.executable,
arguments: command.arguments,
);
if (result.exitCode != 0) {
throw ProcessException(
command.executable,
command.arguments,
'${result.stdout}\n${result.stderr}',
result.exitCode,
);
}
}
DesktopNotificationCommand _buildCommand({
required NotificationEvent event,
required NotificationContext context,
}) {
final headline = switch (event) {
NotificationEvent.mentionDetected => 'Mention detected',
NotificationEvent.replyPosted => 'Reply posted',
NotificationEvent.noReply => 'No reply',
NotificationEvent.responderFailed => 'Responder failed',
final title = 'code_work_spawner: ${notificationHeadline(event)}';
final body = formatDesktopNotificationMessage(
event: event,
context: context,
);
return switch (_platform) {
DesktopNotificationPlatform.linux => DesktopNotificationCommand(
executable: 'notify-send',
arguments: [title, body],
),
DesktopNotificationPlatform.macos => DesktopNotificationCommand(
executable: 'osascript',
arguments: [
'-e',
'display notification ${_appleScriptLiteral(body)} '
'with title ${_appleScriptLiteral(title)}',
],
),
DesktopNotificationPlatform.windows => DesktopNotificationCommand(
executable: 'powershell',
arguments: [
'-NoProfile',
'-Command',
_buildWindowsToastCommand(title: title, body: body),
],
),
DesktopNotificationPlatform.unsupported => throw UnsupportedError(
'Desktop notifications are not supported on this platform.',
),
};
final details = <String>[
'**$headline**',
'`${context.projectKey}#${context.issueNumber}` ${context.issueTitle}',
context.issueUrl,
'repo: `${context.repo}`',
'trigger: `${context.trigger}`',
if (context.mode != null) 'mode: `${context.mode}`',
if (context.responderId != null) 'responder: `${context.responderId}`',
if (context.decision != null) 'decision: `${context.decision}`',
if (context.summary != null && context.summary!.isNotEmpty)
'summary: ${context.summary}',
if (context.errorSummary != null && context.errorSummary!.isNotEmpty)
'error: ${context.errorSummary}',
];
return details.join('\n');
}
}
DesktopNotificationPlatform detectDesktopNotificationPlatform() {
if (Platform.isLinux) {
return DesktopNotificationPlatform.linux;
}
if (Platform.isMacOS) {
return DesktopNotificationPlatform.macos;
}
if (Platform.isWindows) {
return DesktopNotificationPlatform.windows;
}
return DesktopNotificationPlatform.unsupported;
}
Future<ProcessResult> _runDesktopNotificationCommand({
required String executable,
required List<String> arguments,
}) {
return Process.run(executable, arguments);
}
String notificationHeadline(NotificationEvent event) {
return switch (event) {
NotificationEvent.mentionDetected => 'Mention detected',
NotificationEvent.replyPosted => 'Reply posted',
NotificationEvent.noReply => 'No reply',
NotificationEvent.responderFailed => 'Responder failed',
};
}
List<String> _notificationDetailLines(NotificationContext context) {
return <String>[
'${context.projectKey}#${context.issueNumber} ${context.issueTitle}',
context.issueUrl,
'repo: ${context.repo}',
'trigger: ${context.trigger}',
if (context.mode != null) 'mode: ${context.mode}',
if (context.responderId != null) 'responder: ${context.responderId}',
if (context.decision != null) 'decision: ${context.decision}',
if (context.summary != null && context.summary!.isNotEmpty)
'summary: ${context.summary}',
if (context.errorSummary != null && context.errorSummary!.isNotEmpty)
'error: ${context.errorSummary}',
];
}
String formatDesktopNotificationMessage({
required NotificationEvent event,
required NotificationContext context,
}) {
return '${notificationHeadline(event)}\n${_notificationDetailLines(context).join('\n')}';
}
String formatDiscordNotificationMessage({
required NotificationEvent event,
required NotificationContext context,
}) {
final details = <String>[
'**${notificationHeadline(event)}**',
'`${context.projectKey}#${context.issueNumber}` ${context.issueTitle}',
context.issueUrl,
'repo: `${context.repo}`',
'trigger: `${context.trigger}`',
if (context.mode != null) 'mode: `${context.mode}`',
if (context.responderId != null) 'responder: `${context.responderId}`',
if (context.decision != null) 'decision: `${context.decision}`',
if (context.summary != null && context.summary!.isNotEmpty)
'summary: ${context.summary}',
if (context.errorSummary != null && context.errorSummary!.isNotEmpty)
'error: ${context.errorSummary}',
];
return details.join('\n');
}
String _appleScriptLiteral(String value) {
return '"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"';
}
String _buildWindowsToastCommand({
required String title,
required String body,
}) {
final escapedTitle = _escapeXmlText(title);
final escapedBody = _escapeXmlText(body);
return r'''
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
$template = @"
<toast>
<visual>
<binding template="ToastGeneric">
<text>''' +
escapedTitle +
r'''</text>
<text>''' +
escapedBody +
r'''</text>
</binding>
</visual>
</toast>
"@
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($template)
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('code_work_spawner')
$notifier.Show($toast)
''';
}
String _escapeXmlText(String value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}

View File

@ -186,7 +186,7 @@ projects:
/// Given camelCase config with default notifications and a project-specific notification override
/// When loading the app config from disk
/// Then the project replaces inherited notifications with its own list
/// And notification event enums are parsed from snake_case values
/// And notification enums are parsed from their config values
/// ```
test('Merge notification defaults with project overrides', () async {
// Given camelCase config with default notifications and a project-specific notification override.
@ -228,11 +228,53 @@ projects:
expect(notification.webhookUrl, 'https://project.example.test/webhook');
expect(notification.username, 'cws');
// And notification event enums are parsed from snake_case values.
// And notification enums are parsed from their config values.
expect(notification.events, {
NotificationEvent.responderFailed,
NotificationEvent.noReply,
});
expect(notification.type, NotificationType.discordWebhook);
});
/// ```gherkin
/// Scenario: Load a desktop notifier from config
/// Given camelCase config with a desktop notifier
/// When loading the app config from disk
/// Then the notifier type is parsed from the config value
/// And the configured events are available to the app
/// ```
test('Load a desktop notifier from config', () async {
// Given camelCase config with a desktop notifier.
final tempDir = await Directory.systemTemp.createTemp(
'cws-desktop-notification-config-',
);
final configFile = File(p.join(tempDir.path, 'agent-orchestrator.yaml'));
await configFile.writeAsString('''
defaults:
issueAssistant:
notifications:
- type: desktop
enabled: true
events: ["mention_detected", "reply_posted"]
projects:
sample:
repo: owner/sample
path: ${tempDir.path}
''');
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
// Then the notifier type is parsed from the config value.
final notification =
config.projects['sample']!.issueAssistant.notifications.single;
expect(notification.type, NotificationType.desktop);
// And the configured events are available to the app.
expect(notification.events, {
NotificationEvent.mentionDetected,
NotificationEvent.replyPosted,
});
});
/// ```gherkin

View File

@ -39,6 +39,7 @@ void main() {
expect(stdoutText, contains('# responders:'));
expect(stdoutText, contains('# notifications:'));
expect(stdoutText, contains(r'# webhookUrl: ${CWS_DISCORD_WEBHOOK}'));
expect(stdoutText, contains('# - type: desktop'));
expect(stdoutText, contains('projects:'));
// And the printed config is valid when written to disk and loaded.

110
test/notifier_test.dart Normal file
View File

@ -0,0 +1,110 @@
import 'dart:io';
import 'package:code_work_spawner/code_work_spawner.dart';
import 'package:test/test.dart';
void main() {
/// ```gherkin
/// Feature: Desktop notifier
///
/// As a maintainer running code_work_spawner locally
/// I want desktop notifications for assistant events
/// So that I can notice mentions and failures without watching the terminal
/// ```
group('DesktopNotifier', () {
/// ```gherkin
/// Scenario: Send a Linux desktop notification for a posted reply
/// Given a desktop notifier configured for Linux
/// When it sends a reply_posted notification
/// Then it invokes notify-send with a readable title and body
/// ```
test('Send a Linux desktop notification for a posted reply', () async {
// Given a desktop notifier configured for Linux.
String? capturedExecutable;
List<String>? capturedArguments;
final notifier = DesktopNotifier(
NotificationConfig(
type: NotificationType.desktop,
enabled: true,
events: {NotificationEvent.replyPosted},
webhookUrl: null,
username: null,
avatarUrl: null,
),
platform: DesktopNotificationPlatform.linux,
commandRunner: ({required executable, required arguments}) async {
capturedExecutable = executable;
capturedArguments = List<String>.from(arguments);
return ProcessResult(0, 0, '', '');
},
);
const context = NotificationContext(
projectKey: 'sample',
repo: 'owner/sample',
issueNumber: 61,
issueTitle: 'Need architecture help',
issueUrl: 'https://example.test/issues/61',
trigger: '@helper',
mode: 'planning',
summary: 'posted planning advice',
);
// When it sends a reply_posted notification.
await notifier.notify(
event: NotificationEvent.replyPosted,
context: context,
);
// Then it invokes notify-send with a readable title and body.
expect(capturedExecutable, 'notify-send');
expect(capturedArguments, hasLength(2));
expect(capturedArguments!.first, 'code_work_spawner: Reply posted');
expect(
capturedArguments!.last,
contains('sample#61 Need architecture help'),
);
expect(
capturedArguments!.last,
contains('summary: posted planning advice'),
);
});
/// ```gherkin
/// Scenario: Reject unsupported desktop platforms
/// Given a desktop notifier configured for an unsupported platform
/// When it sends a mention_detected notification
/// Then it throws an UnsupportedError
/// ```
test('Reject unsupported desktop platforms', () async {
// Given a desktop notifier configured for an unsupported platform.
final notifier = DesktopNotifier(
NotificationConfig(
type: NotificationType.desktop,
enabled: true,
events: {NotificationEvent.mentionDetected},
webhookUrl: null,
username: null,
avatarUrl: null,
),
platform: DesktopNotificationPlatform.unsupported,
);
const context = NotificationContext(
projectKey: 'sample',
repo: 'owner/sample',
issueNumber: 61,
issueTitle: 'Need architecture help',
issueUrl: 'https://example.test/issues/61',
trigger: '@helper',
);
// When it sends a mention_detected notification.
final future = notifier.notify(
event: NotificationEvent.mentionDetected,
context: context,
);
// Then it throws an UnsupportedError.
await expectLater(future, throwsA(isA<UnsupportedError>()));
});
});
}