Skip to content

Commit 3e963ad

Browse files
nunocoracaoclaude
andcommitted
fix: v0.4.0 bug fixes — daemon crash, GUI reactivity, CLI wildfire chaining
- Daemon: hasAppBundle() pre-check + @try/@catch for macOS notifications outside .app bundle - Daemon: strip CLAUDECODE env var from agent subprocess to prevent nesting issues - Daemon: serialize tray updates through single goroutine to prevent concurrent Cocoa crashes - Daemon: fix agent manager deadlock by running onChangeFn in goroutine during state persist - GUI: optimistic local store update for project color changes - GUI: remove flawed shallowEqualArray check that suppressed task store updates - CLI: handle stream errors gracefully in wildfire/start-all chaining mode Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0a8ff73 commit 3e963ad

File tree

11 files changed

+141
-38
lines changed

11 files changed

+141
-38
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## [0.4.0] Ember
4+
5+
### Fixed
6+
7+
- Daemon crash (exit code 2) when macOS notification fires outside `.app` bundle — `hasAppBundle()` pre-check and `@try/@catch` prevent `NSInternalInconsistencyException`
8+
- Agent subprocess inheriting `CLAUDECODE` env var — stripped from child process environment to prevent Claude Code nesting issues
9+
- Project color not updating in sidebar/dashboard after changing in settings — optimistic local store update now re-renders immediately
10+
- Tasks not updating in GUI when chat agent creates them on disk — removed flawed shallow comparison that suppressed store updates from protobuf-es objects
11+
- CLI wildfire/start-all crashing with "stream error: no agent running" during task transitions — stream errors are now handled gracefully in chaining mode
12+
- System tray concurrent update crashes — serialized Cocoa API calls through a single goroutine with debouncing
13+
- Agent manager deadlock when `onChangeFn` calls `ListAgents()` during state persist — moved callback to a goroutine
14+
315
## [0.3.0] Ember
416

517
### Added

gui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "watchfire",
3-
"version": "0.0.1",
3+
"version": "0.1.0",
44
"description": "Remote control for AI coding agents",
55
"main": "./out/main/index.js",
66
"scripts": {

gui/src/renderer/src/stores/projects-store.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface ProjectsState {
1212
fetchProjects: () => Promise<void>
1313
fetchAgentStatus: (projectId: string) => Promise<void>
1414
fetchAllAgentStatuses: () => Promise<void>
15+
updateProjectLocal: (projectId: string, updates: Partial<Project>) => void
1516
reorderProjects: (projectIds: string[]) => Promise<void>
1617
removeProject: (projectId: string) => Promise<void>
1718
}
@@ -56,6 +57,14 @@ export const useProjectsStore = create<ProjectsState>((set, get) => ({
5657
}
5758
},
5859

60+
updateProjectLocal: (projectId, updates) => {
61+
set((s) => ({
62+
projects: s.projects.map((p) =>
63+
p.projectId === projectId ? { ...p, ...updates } : p
64+
)
65+
}))
66+
},
67+
5968
reorderProjects: async (projectIds) => {
6069
// Optimistic local reorder
6170
const { projects } = get()

gui/src/renderer/src/stores/tasks-store.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { create } from 'zustand'
22
import type { Task } from '../generated/watchfire_pb'
33
import { getTaskClient } from '../lib/grpc-client'
4-
import { shallowEqualArray } from '../lib/utils'
54

65
interface TasksState {
76
tasks: Record<string, Task[]>
@@ -37,11 +36,6 @@ export const useTasksStore = create<TasksState>((set, get) => ({
3736
try {
3837
const client = getTaskClient()
3938
const resp = await client.listTasks({ projectId, includeDeleted })
40-
const existing = get().tasks[projectId]
41-
if (existing && shallowEqualArray(existing as Record<string, unknown>[], resp.tasks as Record<string, unknown>[])) {
42-
set({ loading: false })
43-
return
44-
}
4539
set((s) => ({
4640
tasks: { ...s.tasks, [projectId]: resp.tasks },
4741
loading: false

gui/src/renderer/src/views/ProjectView/SettingsTab.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,18 @@ export function SettingsTab({ projectId, project }: Props) {
3939
}, [project])
4040

4141
const fetchProjects = useProjectsStore((s) => s.fetchProjects)
42+
const updateProjectLocal = useProjectsStore((s) => s.updateProjectLocal)
4243

4344
const save = useCallback(async (updates: Record<string, unknown>) => {
45+
updateProjectLocal(projectId, updates as Partial<Project>)
4446
try {
4547
const client = getProjectClient()
4648
await client.updateProject({ projectId, ...updates })
4749
await fetchProjects()
4850
} catch (err) {
4951
toast('Failed to save settings', 'error')
5052
}
51-
}, [projectId, fetchProjects])
53+
}, [projectId, fetchProjects, updateProjectLocal])
5254

5355
const debouncedSave = (updates: Record<string, unknown>) => {
5456
if (timerRef.current) clearTimeout(timerRef.current)

internal/cli/agent.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,14 @@ func streamOutput(ctx context.Context, stream pb.AgentService_SubscribeRawOutput
202202
chunk, err := stream.Recv()
203203
if err != nil {
204204
if err == io.EOF || ctx.Err() != nil {
205-
// Stream ended
206-
} else {
205+
// Stream ended normally
206+
} else if !isChaining {
207+
// Non-chaining: unexpected stream error is fatal
207208
_ = term.Restore(int(os.Stdin.Fd()), oldState)
208209
return fmt.Errorf("stream error: %w", err)
209210
}
211+
// In chaining mode, stream errors (e.g. "no agent running") are
212+
// expected during task transitions — fall through to re-subscribe.
210213

211214
// Non-chaining modes: done
212215
if !isChaining {

internal/daemon/agent/manager.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -603,7 +603,9 @@ func (m *Manager) persistStateLocked() {
603603
log.Printf("Failed to persist agent state: %v", err)
604604
}
605605

606-
// Notify tray/listeners of state change
606+
// Notify tray/listeners of state change in a goroutine.
607+
// Must use goroutine because onChangeFn calls ListAgents() which needs m.mu.RLock(),
608+
// and persistStateLocked is called while m.mu is write-locked.
607609
if m.onChangeFn != nil {
608610
go m.onChangeFn()
609611
}

internal/daemon/agent/sandbox.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ func SpawnSandboxed(homeDir, projectDir, command string, args ...string) (*exec.
128128

129129
// Set environment
130130
env := os.Environ()
131+
env = removeEnv(env, "CLAUDECODE") // prevent nested-session detection
131132
env = setEnv(env, "TERM", "xterm-256color")
132133
env = setEnv(env, "COLORTERM", "truecolor")
133134

@@ -144,6 +145,17 @@ func SpawnSandboxed(homeDir, projectDir, command string, args ...string) (*exec.
144145
return cmd, tmpFile.Name(), nil
145146
}
146147

148+
// removeEnv removes an environment variable from a slice.
149+
func removeEnv(env []string, key string) []string {
150+
prefix := key + "="
151+
for i, e := range env {
152+
if strings.HasPrefix(e, prefix) {
153+
return append(env[:i], env[i+1:]...)
154+
}
155+
}
156+
return env
157+
}
158+
147159
// setEnv sets or replaces an environment variable in a slice.
148160
func setEnv(env []string, key, value string) []string {
149161
prefix := key + "="

internal/daemon/notify/notify_darwin.m

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,56 @@
44

55
void SetDarwinAppIcon(const void *data, int len) {
66
@autoreleasepool {
7-
NSData *imgData = [NSData dataWithBytes:data length:len];
8-
NSImage *icon = [[NSImage alloc] initWithData:imgData];
9-
if (icon) {
10-
[NSApp setApplicationIconImage:icon];
7+
@try {
8+
NSData *imgData = [NSData dataWithBytes:data length:len];
9+
NSImage *icon = [[NSImage alloc] initWithData:imgData];
10+
if (icon) {
11+
[NSApp setApplicationIconImage:icon];
12+
}
13+
} @catch (NSException *exception) {
14+
NSLog(@"Watchfire: set app icon failed: %@", exception.reason);
1115
}
1216
}
1317
}
1418

19+
// Returns YES if the process has a valid app bundle (required for UNUserNotificationCenter).
20+
static BOOL hasAppBundle(void) {
21+
NSBundle *bundle = [NSBundle mainBundle];
22+
return bundle != nil && [bundle bundleIdentifier] != nil;
23+
}
24+
1525
void SendDarwinNotification(const char *title, const char *message) {
1626
@autoreleasepool {
17-
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
18-
19-
// Request authorization (no-op after first grant).
20-
[center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert | UNAuthorizationOptionSound)
21-
completionHandler:^(BOOL granted, NSError *error) {}];
22-
23-
UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init];
24-
content.title = [NSString stringWithUTF8String:title];
25-
content.body = [NSString stringWithUTF8String:message];
26-
content.sound = [UNNotificationSound defaultSound];
27-
28-
NSString *identifier = [[NSUUID UUID] UUIDString];
29-
UNNotificationRequest *request =
30-
[UNNotificationRequest requestWithIdentifier:identifier
31-
content:content
32-
trigger:nil];
33-
34-
[center addNotificationRequest:request withCompletionHandler:^(NSError *error) {
35-
if (error) {
36-
NSLog(@"Watchfire notification error: %@", error);
27+
@try {
28+
if (!hasAppBundle()) {
29+
NSLog(@"Watchfire: skipping notification (no app bundle)");
30+
return;
3731
}
38-
}];
32+
33+
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
34+
35+
// Request authorization (no-op after first grant).
36+
[center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert | UNAuthorizationOptionSound)
37+
completionHandler:^(BOOL granted, NSError *error) {}];
38+
39+
UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init];
40+
content.title = [NSString stringWithUTF8String:title];
41+
content.body = [NSString stringWithUTF8String:message];
42+
content.sound = [UNNotificationSound defaultSound];
43+
44+
NSString *identifier = [[NSUUID UUID] UUIDString];
45+
UNNotificationRequest *request =
46+
[UNNotificationRequest requestWithIdentifier:identifier
47+
content:content
48+
trigger:nil];
49+
50+
[center addNotificationRequest:request withCompletionHandler:^(NSError *error) {
51+
if (error) {
52+
NSLog(@"Watchfire notification error: %@", error);
53+
}
54+
}];
55+
} @catch (NSException *exception) {
56+
NSLog(@"Watchfire: notification failed: %@", exception.reason);
57+
}
3958
}
4059
}

internal/daemon/tray/tray.go

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ var (
2828
onExit func()
2929
portItem *systray.MenuItem
3030

31+
// Serialized tray update channel (prevents concurrent Cocoa API calls)
32+
updateCh chan struct{} // signal channel
33+
updateMu sync.Mutex // protects latestAgents
34+
latestAgents []AgentInfo
35+
3136
// Dynamic tray icons
3237
iconIdle []byte
3338
iconActive []byte
@@ -131,6 +136,10 @@ func onReady() {
131136
// Initialize previous agents map
132137
previousAgents = make(map[string]AgentInfo)
133138

139+
// Start serialized tray update processor
140+
updateCh = make(chan struct{}, 1)
141+
go processUpdates()
142+
134143
// Start the daemon services
135144
if onStart != nil {
136145
onStart()
@@ -275,8 +284,49 @@ func startProjectAtSlot(slot int, mode string) {
275284
go state.StartAgent(projectID, mode)
276285
}
277286

278-
// UpdateAgents refreshes the agent menu items, project slots, tray icon, and tooltip.
287+
// UpdateAgents sends an agent update to the serialized update processor.
288+
// This is non-blocking and safe to call from any goroutine concurrently.
279289
func UpdateAgents(agents []AgentInfo) {
290+
updateMu.Lock()
291+
latestAgents = agents
292+
updateMu.Unlock()
293+
294+
// Signal that there's an update (non-blocking)
295+
select {
296+
case updateCh <- struct{}{}:
297+
default:
298+
// Signal already pending — processUpdates will pick up latestAgents
299+
}
300+
}
301+
302+
// processUpdates drains updateCh and applies updates serially with debouncing.
303+
// This ensures all systray/Cocoa API calls happen on a single goroutine.
304+
func processUpdates() {
305+
for range updateCh {
306+
// Debounce: wait briefly for more updates before applying
307+
time.Sleep(50 * time.Millisecond)
308+
309+
// Drain any pending signals
310+
drain:
311+
for {
312+
select {
313+
case <-updateCh:
314+
default:
315+
break drain
316+
}
317+
}
318+
319+
// Read the latest agents snapshot
320+
updateMu.Lock()
321+
agents := latestAgents
322+
updateMu.Unlock()
323+
324+
applyAgentUpdate(agents)
325+
}
326+
}
327+
328+
// applyAgentUpdate performs the actual systray updates. Only called from processUpdates.
329+
func applyAgentUpdate(agents []AgentInfo) {
280330
// Detect agent completions for notifications
281331
detectCompletions(previousAgents, agents)
282332

0 commit comments

Comments
 (0)