From e6acf0fb09cf6d2b0decbd152d503ee220a18105 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Mon, 5 Jan 2026 14:58:01 +0200 Subject: [PATCH 01/24] feat(desktop): add 3-color workspace status indicators Implement workspace status indicators showing agent lifecycle states: - Amber (pulsing): Agent actively processing - Red (pulsing): Agent blocked, needs user input - Green (static): Agent completed, ready for review Key changes: - Add PaneStatus type (idle/working/permission/review) - Update notification server for Start/Stop/Permission events - Add StatusIndicator shared component - Update tabs store with status tracking - Add dev/prod separation for agent hooks - Add SUPERSET_ENV and SUPERSET_HOOK_VERSION env vars - Add projectColor to workspace sidebar (colored dots for projects) Co-Authored-By: Claude --- apps/desktop/docs/EXTERNAL_FILES.md | 99 +++++++++++ .../src/lib/trpc/routers/notifications.ts | 19 ++- .../src/lib/trpc/routers/ui-state/index.ts | 2 +- .../main/lib/agent-setup/agent-wrappers.ts | 57 +++++-- .../desktop/src/main/lib/agent-setup/index.ts | 4 + .../src/main/lib/agent-setup/notify-hook.ts | 19 ++- .../src/main/lib/notifications/server.test.ts | 41 +++++ .../src/main/lib/notifications/server.ts | 147 +++++++++++++++- .../desktop/src/main/lib/terminal/env.test.ts | 12 ++ apps/desktop/src/main/lib/terminal/env.ts | 18 +- apps/desktop/src/main/windows/main.ts | 11 +- .../StatusIndicator/StatusIndicator.tsx | 68 ++++++++ .../main/components/StatusIndicator/index.ts | 5 + .../TopBar/WorkspaceSidebarControl.tsx | 9 +- .../WorkspaceListItem/WorkspaceListItem.tsx | 8 +- .../TabsContent/GroupStrip/GroupItem.tsx | 14 +- .../TabsContent/GroupStrip/GroupStrip.tsx | 25 ++- .../desktop/src/renderer/stores/tabs/store.ts | 157 ++++++++++++------ .../desktop/src/renderer/stores/tabs/types.ts | 14 +- .../stores/tabs/useAgentHookListener.ts | 60 +++++-- apps/desktop/src/shared/constants.ts | 2 +- apps/desktop/src/shared/tabs-types.ts | 11 +- 22 files changed, 670 insertions(+), 132 deletions(-) create mode 100644 apps/desktop/docs/EXTERNAL_FILES.md create mode 100644 apps/desktop/src/main/lib/notifications/server.test.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/StatusIndicator/StatusIndicator.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/StatusIndicator/index.ts diff --git a/apps/desktop/docs/EXTERNAL_FILES.md b/apps/desktop/docs/EXTERNAL_FILES.md new file mode 100644 index 00000000000..9e3f5d0fca9 --- /dev/null +++ b/apps/desktop/docs/EXTERNAL_FILES.md @@ -0,0 +1,99 @@ +# External Files Written by Superset Desktop + +This document lists all files written by the Superset desktop app outside of user projects. +Understanding these files is critical for maintaining dev/prod separation and avoiding conflicts. + +## Environment-Specific Directories + +The app uses different home directories based on environment: +- **Development**: `~/.superset-dev/` +- **Production**: `~/.superset/` + +This separation prevents dev and prod from interfering with each other. + +## Files in `~/.superset[-dev]/` + +### `bin/` - Agent Wrapper Scripts + +| File | Purpose | +|------|---------| +| `claude` | Wrapper for Claude Code CLI that injects notification hooks | +| `codex` | Wrapper for Codex CLI that injects notification hooks | +| `opencode` | Wrapper for OpenCode CLI that sets `OPENCODE_CONFIG_DIR` | + +These wrappers are added to `PATH` via shell integration, allowing them to intercept +agent commands and inject Superset-specific configuration. + +### `hooks/` - Notification Hook Scripts + +| File | Purpose | +|------|---------| +| `notify.sh` | Shell script called by agents when they complete or need input | +| `claude-settings.json` | Claude Code settings file with hook configuration | +| `opencode/plugin/superset-notify.js` | OpenCode plugin for lifecycle events | + +### `zsh/` and `bash/` - Shell Integration + +| File | Purpose | +|------|---------| +| `init.zsh` | Zsh initialization script (sources .zshrc, sets up PATH) | +| `init.bash` | Bash initialization script (sources .bashrc, sets up PATH) | + +## Global Files (AVOID ADDING NEW ONES) + +**DO NOT write to global locations** like `~/.config/`, `~/Library/`, etc. +These cause dev/prod conflicts when both environments are running. + +### Known Issues with Global Files + +Previously, the OpenCode plugin was written to `~/.config/opencode/plugin/superset-notify.js`. +This caused severe issues: +1. Dev would overwrite prod's plugin with incompatible protocol +2. Prod terminals would send events that dev's server couldn't handle +3. Users received spam notifications for every agent message + +**Solution**: The global plugin is no longer written. On startup, any stale global plugin +with our marker is deleted to prevent conflicts from older versions. + +## Shell RC File Modifications + +The app modifies shell RC files to add the Superset bin directory to PATH: + +| Shell | RC File | Modification | +|-------|---------|--------------| +| Zsh | `~/.zshrc` | Prepends `~/.superset[-dev]/bin` to PATH | +| Bash | `~/.bashrc` | Prepends `~/.superset[-dev]/bin` to PATH | + +## Terminal Environment Variables + +Each terminal session receives these environment variables: + +| Variable | Purpose | +|----------|---------| +| `SUPERSET_PANE_ID` | Unique identifier for the terminal pane | +| `SUPERSET_TAB_ID` | Identifier for the containing tab | +| `SUPERSET_WORKSPACE_ID` | Identifier for the workspace | +| `SUPERSET_WORKSPACE_NAME` | Human-readable workspace name | +| `SUPERSET_WORKSPACE_PATH` | Filesystem path to the workspace | +| `SUPERSET_ROOT_PATH` | Root path of the project | +| `SUPERSET_PORT` | Port for the notification server | +| `SUPERSET_ENV` | Environment (`development` or `production`) | +| `SUPERSET_HOOK_VERSION` | Hook protocol version for compatibility | + +## Adding New External Files + +Before adding new files outside of `~/.superset[-dev]/`: + +1. **Consider if it's necessary** - Can you use the environment-specific directory instead? +2. **Check for conflicts** - Will dev and prod overwrite each other? +3. **Update this document** - Add the file to the appropriate section +4. **Add cleanup logic** - If migrating from global to local, clean up the old location + +## Debugging Cross-Environment Issues + +If you suspect dev/prod cross-talk: + +1. Check logs for "Environment mismatch" warnings +2. Verify `SUPERSET_ENV` and `SUPERSET_PORT` are set correctly in terminal +3. Delete stale global files: `rm -rf ~/.config/opencode/plugin/superset-notify.js` +4. Restart both dev and prod apps to regenerate hooks diff --git a/apps/desktop/src/lib/trpc/routers/notifications.ts b/apps/desktop/src/lib/trpc/routers/notifications.ts index eb539b8122a..f90d264b6f6 100644 --- a/apps/desktop/src/lib/trpc/routers/notifications.ts +++ b/apps/desktop/src/lib/trpc/routers/notifications.ts @@ -1,6 +1,6 @@ import { observable } from "@trpc/server/observable"; import { - type AgentCompleteEvent, + type AgentLifecycleEvent, type NotificationIds, notificationsEmitter, } from "main/lib/notifications/server"; @@ -9,8 +9,8 @@ import { publicProcedure, router } from ".."; type NotificationEvent = | { - type: typeof NOTIFICATION_EVENTS.AGENT_COMPLETE; - data?: AgentCompleteEvent; + type: typeof NOTIFICATION_EVENTS.AGENT_LIFECYCLE; + data?: AgentLifecycleEvent; } | { type: typeof NOTIFICATION_EVENTS.FOCUS_TAB; data?: NotificationIds }; @@ -18,21 +18,24 @@ export const createNotificationsRouter = () => { return router({ subscribe: publicProcedure.subscription(() => { return observable((emit) => { - const onComplete = (data: AgentCompleteEvent) => { - emit.next({ type: NOTIFICATION_EVENTS.AGENT_COMPLETE, data }); + const onLifecycle = (data: AgentLifecycleEvent) => { + emit.next({ type: NOTIFICATION_EVENTS.AGENT_LIFECYCLE, data }); }; const onFocusTab = (data: NotificationIds) => { emit.next({ type: NOTIFICATION_EVENTS.FOCUS_TAB, data }); }; - notificationsEmitter.on(NOTIFICATION_EVENTS.AGENT_COMPLETE, onComplete); + notificationsEmitter.on( + NOTIFICATION_EVENTS.AGENT_LIFECYCLE, + onLifecycle, + ); notificationsEmitter.on(NOTIFICATION_EVENTS.FOCUS_TAB, onFocusTab); return () => { notificationsEmitter.off( - NOTIFICATION_EVENTS.AGENT_COMPLETE, - onComplete, + NOTIFICATION_EVENTS.AGENT_LIFECYCLE, + onLifecycle, ); notificationsEmitter.off(NOTIFICATION_EVENTS.FOCUS_TAB, onFocusTab); }; diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index afbff9fc94b..03371e0e4bd 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -36,7 +36,7 @@ const paneSchema = z.object({ type: z.enum(["terminal", "webview", "file-viewer"]), name: z.string(), isNew: z.boolean().optional(), - needsAttention: z.boolean().optional(), + status: z.enum(["idle", "working", "permission", "review"]).optional(), initialCommands: z.array(z.string()).optional(), initialCwd: z.string().optional(), url: z.string().optional(), diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index 0ced8cdacb6..349cdf2895c 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -12,7 +12,7 @@ import { export const WRAPPER_MARKER = "# Superset agent-wrapper v1"; export const CLAUDE_SETTINGS_FILE = "claude-settings.json"; export const OPENCODE_PLUGIN_FILE = "superset-notify.js"; -export const OPENCODE_PLUGIN_MARKER = "// Superset opencode plugin v3"; +export const OPENCODE_PLUGIN_MARKER = "// Superset opencode plugin v4"; const REAL_BINARY_RESOLVER = `find_real_binary() { local name="$1" @@ -72,6 +72,7 @@ export function getOpenCodeGlobalPluginPath(): string { export function getClaudeSettingsContent(notifyPath: string): string { const settings = { hooks: { + UserPromptSubmit: [{ hooks: [{ type: "command", command: notifyPath }] }], Stop: [{ hooks: [{ type: "command", command: notifyPath }] }], PermissionRequest: [ { matcher: "*", hooks: [{ type: "command", command: notifyPath }] }, @@ -144,7 +145,7 @@ export function getOpenCodePluginContent(notifyPath: string): string { " * Superset Notification Plugin for OpenCode", " *", " * This plugin sends desktop notifications when OpenCode sessions need attention.", - " * It hooks into session.idle, session.error, and permission.ask events.", + " * It hooks into session.status (busy/idle), session.error, and permission.ask events.", " *", " * IMPORTANT: Subagent/Background Task Filtering", " * --------------------------------------------", @@ -164,8 +165,8 @@ export function getOpenCodePluginContent(notifyPath: string): string { " * @see https://github.com/sst/opencode/blob/dev/packages/app/src/context/notification.tsx", " */", "export const SupersetNotifyPlugin = async ({ $, client }) => {", - " if (globalThis.__supersetOpencodeNotifyPluginV3) return {};", - " globalThis.__supersetOpencodeNotifyPluginV3 = true;", + " if (globalThis.__supersetOpencodeNotifyPluginV4) return {};", + " globalThis.__supersetOpencodeNotifyPluginV4 = true;", "", " // Only run inside a Superset terminal session", " if (!process?.env?.SUPERSET_TAB_ID) return {};", @@ -216,16 +217,29 @@ export function getOpenCodePluginContent(notifyPath: string): string { "", " return {", " event: async ({ event }) => {", - " // Handle session completion events", - ' if (event.type === "session.idle" || event.type === "session.error") {', + " // Handle session status changes (busy = working, idle = done)", + ' if (event.type === "session.status") {', " const sessionID = event.properties?.sessionID;", + " const status = event.properties?.status;", "", " // Skip notifications for child/subagent sessions", - " // This prevents notification spam when background agents complete", " if (await isChildSession(sessionID)) {", " return;", " }", "", + ' if (status?.type === "busy") {', + ' await notify("Start");', + ' } else if (status?.type === "idle") {', + ' await notify("Stop");', + " }", + " }", + "", + " // Handle session errors (also means session stopped)", + ' if (event.type === "session.error") {', + " const sessionID = event.properties?.sessionID;", + " if (await isChildSession(sessionID)) {", + " return;", + " }", ' await notify("Stop");', " }", " },", @@ -275,24 +289,43 @@ export function createCodexWrapper(): void { } /** - * Creates OpenCode plugin file with notification hooks + * Creates OpenCode plugin file with notification hooks. + * Only writes to environment-specific path - NOT the global path. + * Global path causes dev/prod conflicts when both are running. */ export function createOpenCodePlugin(): void { const pluginPath = getOpenCodePluginPath(); const notifyPath = getNotifyScriptPath(); const content = getOpenCodePluginContent(notifyPath); fs.writeFileSync(pluginPath, content, { mode: 0o644 }); + console.log("[agent-setup] Created OpenCode plugin"); +} + +/** + * Cleans up stale global OpenCode plugin that may have been written by older versions. + * Only removes if the file contains our marker to avoid deleting user-installed plugins. + * This prevents dev/prod cross-talk when both environments are running. + */ +export function cleanupGlobalOpenCodePlugin(): void { try { const globalPluginPath = getOpenCodeGlobalPluginPath(); - fs.mkdirSync(path.dirname(globalPluginPath), { recursive: true }); - fs.writeFileSync(globalPluginPath, content, { mode: 0o644 }); + if (!fs.existsSync(globalPluginPath)) return; + + const content = fs.readFileSync(globalPluginPath, "utf-8"); + // Check for any version of our marker (v1, v2, v3, v4, etc.) + if (content.includes("// Superset opencode plugin")) { + fs.unlinkSync(globalPluginPath); + console.log( + "[agent-setup] Removed stale global OpenCode plugin to prevent dev/prod conflicts", + ); + } } catch (error) { + // Ignore errors - this is best-effort cleanup console.warn( - "[agent-setup] Failed to write global OpenCode plugin:", + "[agent-setup] Failed to cleanup global OpenCode plugin:", error, ); } - console.log("[agent-setup] Created OpenCode plugin"); } /** diff --git a/apps/desktop/src/main/lib/agent-setup/index.ts b/apps/desktop/src/main/lib/agent-setup/index.ts index d0ac5cb3ea4..e2ca3b6c82a 100644 --- a/apps/desktop/src/main/lib/agent-setup/index.ts +++ b/apps/desktop/src/main/lib/agent-setup/index.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import { + cleanupGlobalOpenCodePlugin, createClaudeWrapper, createCodexWrapper, createOpenCodePlugin, @@ -34,6 +35,9 @@ export function setupAgentHooks(): void { fs.mkdirSync(BASH_DIR, { recursive: true }); fs.mkdirSync(OPENCODE_PLUGIN_DIR, { recursive: true }); + // Clean up stale global plugins that may cause dev/prod conflicts + cleanupGlobalOpenCodePlugin(); + // Create scripts createNotifyScript(); createClaudeWrapper(); diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts index c583486a9a5..6940eca38f4 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts @@ -26,17 +26,26 @@ else fi # Extract event type - Claude uses "hook_event_name", Codex uses "type" -EVENT_TYPE=$(echo "$INPUT" | grep -o '"hook_event_name":"[^"]*"' | cut -d'"' -f4) +# Use flexible pattern to handle optional whitespace: "key": "value" or "key":"value" +EVENT_TYPE=$(echo "$INPUT" | grep -oE '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') if [ -z "$EVENT_TYPE" ]; then # Check for Codex "type" field (e.g., "agent-turn-complete") - CODEX_TYPE=$(echo "$INPUT" | grep -o '"type":"[^"]*"' | cut -d'"' -f4) + CODEX_TYPE=$(echo "$INPUT" | grep -oE '"type"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') if [ "$CODEX_TYPE" = "agent-turn-complete" ]; then EVENT_TYPE="Stop" fi fi -# Default to "Stop" if not found -[ -z "$EVENT_TYPE" ] && EVENT_TYPE="Stop" +# NOTE: We intentionally do NOT default to "Stop" if EVENT_TYPE is empty. +# Parse failures should not trigger completion notifications. +# The server will ignore requests with missing eventType (forward compatibility). + +# Map UserPromptSubmit to Start for simpler handling +[ "$EVENT_TYPE" = "UserPromptSubmit" ] && EVENT_TYPE="Start" + +# If no event type was found, skip the notification +# This prevents parse failures from causing false completion notifications +[ -z "$EVENT_TYPE" ] && exit 0 # Timeouts prevent blocking agent completion if notification server is unresponsive curl -sG "http://127.0.0.1:\${SUPERSET_PORT:-${PORTS.NOTIFICATIONS}}/hook/complete" \\ @@ -45,6 +54,8 @@ curl -sG "http://127.0.0.1:\${SUPERSET_PORT:-${PORTS.NOTIFICATIONS}}/hook/comple --data-urlencode "tabId=$SUPERSET_TAB_ID" \\ --data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \\ --data-urlencode "eventType=$EVENT_TYPE" \\ + --data-urlencode "env=$SUPERSET_ENV" \\ + --data-urlencode "version=$SUPERSET_HOOK_VERSION" \\ > /dev/null 2>&1 `; } diff --git a/apps/desktop/src/main/lib/notifications/server.test.ts b/apps/desktop/src/main/lib/notifications/server.test.ts new file mode 100644 index 00000000000..94e095508e9 --- /dev/null +++ b/apps/desktop/src/main/lib/notifications/server.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "bun:test"; +import { mapEventType } from "./server"; + +describe("notifications/server", () => { + describe("mapEventType", () => { + it("should map 'Start' to 'Start'", () => { + expect(mapEventType("Start")).toBe("Start"); + }); + + it("should map 'UserPromptSubmit' to 'Start'", () => { + expect(mapEventType("UserPromptSubmit")).toBe("Start"); + }); + + it("should map 'Stop' to 'Stop'", () => { + expect(mapEventType("Stop")).toBe("Stop"); + }); + + it("should map 'agent-turn-complete' to 'Stop'", () => { + expect(mapEventType("agent-turn-complete")).toBe("Stop"); + }); + + it("should map 'PermissionRequest' to 'PermissionRequest'", () => { + expect(mapEventType("PermissionRequest")).toBe("PermissionRequest"); + }); + + it("should return null for unknown event types (forward compatibility)", () => { + expect(mapEventType("UnknownEvent")).toBeNull(); + expect(mapEventType("FutureEvent")).toBeNull(); + expect(mapEventType("SomeNewHook")).toBeNull(); + }); + + it("should return null for undefined eventType (not default to Stop)", () => { + // This is critical: missing eventType should NOT trigger a completion notification + expect(mapEventType(undefined)).toBeNull(); + }); + + it("should return null for empty string eventType", () => { + expect(mapEventType("")).toBeNull(); + }); + }); +}); diff --git a/apps/desktop/src/main/lib/notifications/server.ts b/apps/desktop/src/main/lib/notifications/server.ts index a27398d46f0..0a453dfd612 100644 --- a/apps/desktop/src/main/lib/notifications/server.ts +++ b/apps/desktop/src/main/lib/notifications/server.ts @@ -1,6 +1,16 @@ import { EventEmitter } from "node:events"; import express from "express"; import { NOTIFICATION_EVENTS } from "shared/constants"; +import { env } from "shared/env.shared"; +import { appState } from "../app-state"; +import { HOOK_PROTOCOL_VERSION } from "../terminal/env"; + +/** + * The environment this server is running in. + * Used to validate incoming hook requests and detect cross-environment issues. + */ +const SERVER_ENV = + env.NODE_ENV === "development" ? "development" : "production"; export interface NotificationIds { paneId?: string; @@ -8,8 +18,8 @@ export interface NotificationIds { workspaceId?: string; } -export interface AgentCompleteEvent extends NotificationIds { - eventType: "Stop" | "PermissionRequest"; +export interface AgentLifecycleEvent extends NotificationIds { + eventType: "Start" | "Stop" | "PermissionRequest"; } export const notificationsEmitter = new EventEmitter(); @@ -29,20 +39,139 @@ app.use((req, res, next) => { next(); }); -// Agent completion hook +/** + * Maps incoming event types to canonical lifecycle events. + * Handles variations from different agent CLIs. + * + * Returns null for unknown events - caller should ignore these gracefully + * to maintain forward compatibility with newer hook versions. + * + * Note: We no longer default missing eventType to "Stop" to prevent + * parse failures from being treated as completions. + * + * @internal Exported for testing + */ +export function mapEventType( + eventType: string | undefined, +): "Start" | "Stop" | "PermissionRequest" | null { + if (!eventType) { + return null; // Missing eventType should be ignored, not treated as Stop + } + if (eventType === "Start" || eventType === "UserPromptSubmit") { + return "Start"; + } + if (eventType === "PermissionRequest") { + return "PermissionRequest"; + } + if (eventType === "Stop" || eventType === "agent-turn-complete") { + return "Stop"; + } + return null; // Unknown events are ignored for forward compatibility +} + +/** + * Resolves paneId from tabId or workspaceId using synced tabs state. + * Falls back to focused pane in active tab. + * + * If a paneId is provided but doesn't exist in state (stale reference), + * we fall through to tabId/workspaceId resolution instead of returning + * an invalid paneId that would corrupt the store. + */ +function resolvePaneId( + paneId: string | undefined, + tabId: string | undefined, + workspaceId: string | undefined, +): string | undefined { + try { + const tabsState = appState.data.tabsState; + if (!tabsState) return undefined; + + // If paneId provided, validate it exists before returning + if (paneId && tabsState.panes?.[paneId]) { + return paneId; + } + // If paneId was provided but doesn't exist, fall through to resolution + + // Try to resolve from tabId + if (tabId) { + const focusedPaneId = tabsState.focusedPaneIds?.[tabId]; + if (focusedPaneId && tabsState.panes?.[focusedPaneId]) { + return focusedPaneId; + } + } + + // Try to resolve from workspaceId + if (workspaceId) { + const activeTabId = tabsState.activeTabIds?.[workspaceId]; + if (activeTabId) { + const focusedPaneId = tabsState.focusedPaneIds?.[activeTabId]; + if (focusedPaneId && tabsState.panes?.[focusedPaneId]) { + return focusedPaneId; + } + } + } + } catch { + // App state not initialized yet, ignore + } + + return undefined; +} + +// Agent lifecycle hook app.get("/hook/complete", (req, res) => { - const { paneId, tabId, workspaceId, eventType } = req.query; + const { + paneId, + tabId, + workspaceId, + eventType, + env: clientEnv, + version, + } = req.query; + + // Environment validation: detect dev/prod cross-talk + // We still return success to not block the agent, but log a warning + if (clientEnv && clientEnv !== SERVER_ENV) { + console.warn( + `[notifications] Environment mismatch: received ${clientEnv} request on ${SERVER_ENV} server. ` + + `This may indicate a stale hook or misconfigured terminal. Ignoring request.`, + ); + return res.json({ success: true, ignored: true, reason: "env_mismatch" }); + } + + // Log version for debugging (helpful when troubleshooting hook issues) + if (version && version !== HOOK_PROTOCOL_VERSION) { + console.log( + `[notifications] Received hook v${version} request (server expects v${HOOK_PROTOCOL_VERSION})`, + ); + } + + const mappedEventType = mapEventType(eventType as string | undefined); + + // Unknown or missing eventType: return success but don't process + // This ensures forward compatibility and doesn't block the agent + if (!mappedEventType) { + if (eventType) { + console.log("[notifications] Ignoring unknown eventType:", eventType); + } + return res.json({ success: true, ignored: true }); + } + + const resolvedPaneId = resolvePaneId( + paneId as string | undefined, + tabId as string | undefined, + workspaceId as string | undefined, + ); - const event: AgentCompleteEvent = { - paneId: paneId as string | undefined, + const event: AgentLifecycleEvent = { + paneId: resolvedPaneId, tabId: tabId as string | undefined, workspaceId: workspaceId as string | undefined, - eventType: eventType === "PermissionRequest" ? "PermissionRequest" : "Stop", + eventType: mappedEventType, }; - notificationsEmitter.emit(NOTIFICATION_EVENTS.AGENT_COMPLETE, event); + notificationsEmitter.emit(NOTIFICATION_EVENTS.AGENT_LIFECYCLE, event); - res.json({ success: true, paneId, tabId }); + res.json({ success: true, paneId: resolvedPaneId, tabId }); }); // Health check diff --git a/apps/desktop/src/main/lib/terminal/env.test.ts b/apps/desktop/src/main/lib/terminal/env.test.ts index 6ed3c03a193..89bda1dfb73 100644 --- a/apps/desktop/src/main/lib/terminal/env.test.ts +++ b/apps/desktop/src/main/lib/terminal/env.test.ts @@ -665,5 +665,17 @@ describe("env", () => { expect(typeof result.SUPERSET_PORT).toBe("string"); }); }); + + it("should include SUPERSET_ENV for dev/prod separation", () => { + const result = buildTerminalEnv(baseParams); + expect(result.SUPERSET_ENV).toBeDefined(); + expect(["development", "production"]).toContain(result.SUPERSET_ENV); + }); + + it("should include SUPERSET_HOOK_VERSION for protocol versioning", () => { + const result = buildTerminalEnv(baseParams); + expect(result.SUPERSET_HOOK_VERSION).toBeDefined(); + expect(result.SUPERSET_HOOK_VERSION).toBe("2"); + }); }); }); diff --git a/apps/desktop/src/main/lib/terminal/env.ts b/apps/desktop/src/main/lib/terminal/env.ts index 7709d66860f..db0429834f9 100644 --- a/apps/desktop/src/main/lib/terminal/env.ts +++ b/apps/desktop/src/main/lib/terminal/env.ts @@ -2,8 +2,16 @@ import { execSync } from "node:child_process"; import os from "node:os"; import defaultShell from "default-shell"; import { PORTS } from "shared/constants"; +import { env } from "shared/env.shared"; import { getShellEnv } from "../agent-setup/shell-wrappers"; +/** + * Current hook protocol version. + * Increment when making breaking changes to the hook protocol. + * The server logs this for debugging version mismatches. + */ +export const HOOK_PROTOCOL_VERSION = "2"; + export const FALLBACK_SHELL = os.platform() === "win32" ? "cmd.exe" : "/bin/sh"; export const SHELL_CRASH_THRESHOLD_MS = 1000; @@ -340,7 +348,7 @@ export function buildTerminalEnv(params: { const shellEnv = getShellEnv(shell); const locale = getLocale(rawBaseEnv); - const env: Record = { + const terminalEnv: Record = { ...baseEnv, ...shellEnv, TERM_PROGRAM: "Superset", @@ -354,7 +362,13 @@ export function buildTerminalEnv(params: { SUPERSET_WORKSPACE_PATH: workspacePath || "", SUPERSET_ROOT_PATH: rootPath || "", SUPERSET_PORT: String(PORTS.NOTIFICATIONS), + // Environment identifier for dev/prod separation + SUPERSET_ENV: env.NODE_ENV === "development" ? "development" : "production", + // Hook protocol version for forward compatibility + SUPERSET_HOOK_VERSION: HOOK_PROTOCOL_VERSION, }; - return env; + delete terminalEnv.GOOGLE_API_KEY; + + return terminalEnv; } diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 0ba5fc33bea..e70fd1cadc8 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -13,7 +13,7 @@ import { appState } from "../lib/app-state"; import { createApplicationMenu, registerMenuHotkeyUpdates } from "../lib/menu"; import { playNotificationSound } from "../lib/notification-sound"; import { - type AgentCompleteEvent, + type AgentLifecycleEvent, notificationsApp, notificationsEmitter, } from "../lib/notifications/server"; @@ -86,10 +86,13 @@ export async function MainWindow() { }, ); - // Handle agent completion notifications + // Handle agent lifecycle notifications (Stop = completion, PermissionRequest = needs input) notificationsEmitter.on( - NOTIFICATION_EVENTS.AGENT_COMPLETE, - (event: AgentCompleteEvent) => { + NOTIFICATION_EVENTS.AGENT_LIFECYCLE, + (event: AgentLifecycleEvent) => { + // Only notify on Stop (completion) and PermissionRequest - not on Start + if (event.eventType === "Start") return; + if (Notification.isSupported()) { const isPermissionRequest = event.eventType === "PermissionRequest"; diff --git a/apps/desktop/src/renderer/screens/main/components/StatusIndicator/StatusIndicator.tsx b/apps/desktop/src/renderer/screens/main/components/StatusIndicator/StatusIndicator.tsx new file mode 100644 index 00000000000..e446e30469e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/StatusIndicator/StatusIndicator.tsx @@ -0,0 +1,68 @@ +import { cn } from "@superset/ui/utils"; +import type { PaneStatus } from "shared/tabs-types"; + +/** Lookup object for status indicator styling - avoids if/else chains */ +const STATUS_CONFIG = { + permission: { + pingColor: "bg-red-400", + dotColor: "bg-red-500", + pulse: true, + tooltip: "Needs input", + }, + working: { + pingColor: "bg-amber-400", + dotColor: "bg-amber-500", + pulse: true, + tooltip: "Agent working", + }, + review: { + pingColor: "", + dotColor: "bg-green-500", + pulse: false, + tooltip: "Ready for review", + }, +} as const satisfies Record< + Exclude, + { pingColor: string; dotColor: string; pulse: boolean; tooltip: string } +>; + +export type ActivePaneStatus = keyof typeof STATUS_CONFIG; + +interface StatusIndicatorProps { + status: ActivePaneStatus; + className?: string; +} + +/** + * Visual indicator for pane/workspace status. + * - Red pulsing: needs user input (permission) + * - Amber pulsing: agent working + * - Green static: ready for review + */ +export function StatusIndicator({ status, className }: StatusIndicatorProps) { + const config = STATUS_CONFIG[status]; + + return ( + + {config.pulse && ( + + )} + + + ); +} + +/** Get tooltip text for a status - for consumers that wrap with Tooltip */ +export function getStatusTooltip(status: ActivePaneStatus): string { + return STATUS_CONFIG[status].tooltip; +} diff --git a/apps/desktop/src/renderer/screens/main/components/StatusIndicator/index.ts b/apps/desktop/src/renderer/screens/main/components/StatusIndicator/index.ts new file mode 100644 index 00000000000..8df166d7f7f --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/StatusIndicator/index.ts @@ -0,0 +1,5 @@ +export { + type ActivePaneStatus, + getStatusTooltip, + StatusIndicator, +} from "./StatusIndicator"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx index 7396e789a63..294754b7bce 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx @@ -24,6 +24,10 @@ export function WorkspaceSidebarControl() { }; const sidebarCollapsed = isCollapsed(); + const hotkeyDisplay = formatHotkeyDisplay( + getHotkey("TOGGLE_WORKSPACE_SIDEBAR"), + getCurrentPlatform(), + ); return ( @@ -46,10 +50,7 @@ export function WorkspaceSidebarControl() { {sidebarCollapsed ? "Expand" : "Collapse"} Workspaces - {formatHotkeyDisplay( - getHotkey("TOGGLE_WORKSPACE_SIDEBAR"), - getCurrentPlatform(), - ).map((key) => ( + {hotkeyDisplay.map((key) => ( {key} ))} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index c1751ae42a0..6c32c56fc85 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -82,8 +82,8 @@ export function WorkspaceListItem({ const rename = useWorkspaceRename(id, name); const tabs = useTabsStore((s) => s.tabs); const panes = useTabsStore((s) => s.panes); - const clearWorkspaceAttention = useTabsStore( - (s) => s.clearWorkspaceAttention, + const clearWorkspaceAttentionStatus = useTabsStore( + (s) => s.clearWorkspaceAttentionStatus, ); const utils = trpc.useUtils(); const openInFinder = trpc.external.openInFinder.useMutation({ @@ -117,7 +117,7 @@ export function WorkspaceListItem({ ); const hasPaneAttention = Object.values(panes) .filter((p) => p != null && workspacePaneIds.has(p.id)) - .some((p) => p.needsAttention); + .some((p) => p.status && p.status !== "idle"); // Show indicator if workspace is manually marked as unread OR has pane-level attention const needsAttention = isUnread || hasPaneAttention; @@ -125,7 +125,7 @@ export function WorkspaceListItem({ const handleClick = () => { if (!rename.isRenaming) { setActiveWorkspace.mutate({ id }); - clearWorkspaceAttention(id); + clearWorkspaceAttentionStatus(id); // Close workspaces list view if open, to show the workspace's terminal view closeWorkspacesList(); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx index 1af0963416e..f9d35e6b6d0 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx @@ -2,13 +2,14 @@ import { Button } from "@superset/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { HiMiniXMark } from "react-icons/hi2"; -import type { Tab } from "renderer/stores/tabs/types"; +import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; +import type { PaneStatus, Tab } from "renderer/stores/tabs/types"; import { getTabDisplayName } from "renderer/stores/tabs/utils"; interface GroupItemProps { tab: Tab; isActive: boolean; - needsAttention: boolean; + status: PaneStatus | null; onSelect: () => void; onClose: () => void; } @@ -16,7 +17,7 @@ interface GroupItemProps { export function GroupItem({ tab, isActive, - needsAttention, + status, onSelect, onClose, }: GroupItemProps) { @@ -39,11 +40,8 @@ export function GroupItem({ {displayName} - {needsAttention && ( - - - - + {status && status !== "idle" && ( + )} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index 66e3893ede4..6728fafce4f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -24,6 +24,7 @@ import { trpc } from "renderer/lib/trpc"; import { usePresets } from "renderer/react-query/presets"; import { useOpenSettings } from "renderer/stores"; import { useTabsStore } from "renderer/stores/tabs/store"; +import type { PaneStatus } from "renderer/stores/tabs/types"; import { GroupItem } from "./GroupItem"; export function GroupStrip() { @@ -74,12 +75,24 @@ export function GroupStrip() { ? activeTabIds[activeWorkspaceId] : null; - // Check which tabs have panes that need attention - const tabsWithAttention = useMemo(() => { - const result = new Set(); + // Compute aggregate status per tab (priority: permission > working > review) + const tabStatusMap = useMemo(() => { + const result = new Map(); for (const pane of Object.values(panes)) { - if (pane.needsAttention) { - result.add(pane.tabId); + if (!pane.status || pane.status === "idle") continue; + + const currentStatus = result.get(pane.tabId); + // Priority: permission > working > review + if (pane.status === "permission") { + result.set(pane.tabId, "permission"); + } else if (pane.status === "working" && currentStatus !== "permission") { + result.set(pane.tabId, "working"); + } else if ( + pane.status === "review" && + currentStatus !== "permission" && + currentStatus !== "working" + ) { + result.set(pane.tabId, "review"); } } return result; @@ -137,7 +150,7 @@ export function GroupStrip() { handleSelectGroup(tab.id)} onClose={() => handleCloseGroup(tab.id)} /> diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 828d0679dc1..0f34ea2d63a 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -199,14 +199,22 @@ export const useTabsStore = create()( ]; } - // Clear needsAttention for the focused pane in the tab being activated - const focusedPaneId = state.focusedPaneIds[tabId]; + // Clear attention status for panes in the selected tab + const tabPaneIds = extractPaneIdsFromLayout(tab.layout); const newPanes = { ...state.panes }; - if (focusedPaneId && newPanes[focusedPaneId]?.needsAttention) { - newPanes[focusedPaneId] = { - ...newPanes[focusedPaneId], - needsAttention: false, - }; + let hasChanges = false; + for (const paneId of tabPaneIds) { + const currentStatus = newPanes[paneId]?.status; + if (currentStatus === "review") { + // User acknowledged completion + newPanes[paneId] = { ...newPanes[paneId], status: "idle" }; + hasChanges = true; + } else if (currentStatus === "permission") { + // Assume permission granted, agent is now working + newPanes[paneId] = { ...newPanes[paneId], status: "working" }; + hasChanges = true; + } + // "working" status is NOT cleared by click - persists until Stop } set({ @@ -218,7 +226,7 @@ export const useTabsStore = create()( ...state.tabHistoryStacks, [workspaceId]: newHistoryStack, }, - panes: newPanes, + ...(hasChanges ? { panes: newPanes } : {}), }); }, @@ -506,46 +514,41 @@ export const useTabsStore = create()( const pane = state.panes[paneId]; if (!pane || pane.tabId !== tabId) return; - // Clear needsAttention for the pane being focused - const newPanes = pane.needsAttention - ? { - ...state.panes, - [paneId]: { ...pane, needsAttention: false }, - } - : state.panes; - set({ focusedPaneIds: { ...state.focusedPaneIds, [tabId]: paneId, }, - panes: newPanes, }); }, markPaneAsUsed: (paneId) => { - set((state) => ({ - panes: { - ...state.panes, - [paneId]: state.panes[paneId] - ? { ...state.panes[paneId], isNew: false } - : state.panes[paneId], - }, - })); + set((state) => { + // Guard: no-op for unknown panes to avoid corrupting panes map + if (!state.panes[paneId]) return state; + return { + panes: { + ...state.panes, + [paneId]: { ...state.panes[paneId], isNew: false }, + }, + }; + }); }, - setNeedsAttention: (paneId, needsAttention) => { - set((state) => ({ + setPaneStatus: (paneId, status) => { + const state = get(); + // Guard: no-op for unknown panes to avoid corrupting panes map with undefined + if (!state.panes[paneId]) return; + + set({ panes: { ...state.panes, - [paneId]: state.panes[paneId] - ? { ...state.panes[paneId], needsAttention } - : state.panes[paneId], + [paneId]: { ...state.panes[paneId], status }, }, - })); + }); }, - clearWorkspaceAttention: (workspaceId) => { + clearWorkspaceAttentionStatus: (workspaceId) => { const state = get(); const workspaceTabs = state.tabs.filter( (t) => t.workspaceId === workspaceId, @@ -561,10 +564,17 @@ export const useTabsStore = create()( const newPanes = { ...state.panes }; let hasChanges = false; for (const paneId of workspacePaneIds) { - if (newPanes[paneId]?.needsAttention) { - newPanes[paneId] = { ...newPanes[paneId], needsAttention: false }; + const currentStatus = newPanes[paneId]?.status; + if (currentStatus === "review") { + // User acknowledged completion + newPanes[paneId] = { ...newPanes[paneId], status: "idle" }; + hasChanges = true; + } else if (currentStatus === "permission") { + // Assume permission granted, Claude is now working + newPanes[paneId] = { ...newPanes[paneId], status: "working" }; hasChanges = true; } + // "working" status is NOT cleared by click - persists until Stop } if (hasChanges) { @@ -573,29 +583,37 @@ export const useTabsStore = create()( }, updatePaneCwd: (paneId, cwd, confirmed) => { - set((state) => ({ - panes: { - ...state.panes, - [paneId]: state.panes[paneId] - ? { ...state.panes[paneId], cwd, cwdConfirmed: confirmed } - : state.panes[paneId], - }, - })); + set((state) => { + // Guard: no-op for unknown panes to avoid corrupting panes map + if (!state.panes[paneId]) return state; + return { + panes: { + ...state.panes, + [paneId]: { + ...state.panes[paneId], + cwd, + cwdConfirmed: confirmed, + }, + }, + }; + }); }, clearPaneInitialData: (paneId) => { - set((state) => ({ - panes: { - ...state.panes, - [paneId]: state.panes[paneId] - ? { - ...state.panes[paneId], - initialCommands: undefined, - initialCwd: undefined, - } - : state.panes[paneId], - }, - })); + set((state) => { + // Guard: no-op for unknown panes to avoid corrupting panes map + if (!state.panes[paneId]) return state; + return { + panes: { + ...state.panes, + [paneId]: { + ...state.panes[paneId], + initialCommands: undefined, + initialCwd: undefined, + }, + }, + }; + }); }, // Split operations @@ -776,7 +794,38 @@ export const useTabsStore = create()( }), { name: "tabs-storage", + version: 2, storage: trpcTabsStorage, + migrate: (persistedState, version) => { + const state = persistedState as TabsState; + if (version < 2 && state.panes) { + // Migrate needsAttention → status + for (const pane of Object.values(state.panes)) { + // biome-ignore lint/suspicious/noExplicitAny: migration from old schema + const legacyPane = pane as any; + if (legacyPane.needsAttention === true) { + pane.status = "review"; + } + delete legacyPane.needsAttention; + } + } + return state; + }, + merge: (persistedState, currentState) => { + const persisted = persistedState as TabsState; + // Clear stale transient statuses on startup: + // - "working": Agent can't be working if app just restarted + // - "permission": Permission dialog is gone after restart + // Note: "review" is intentionally preserved so users see missed completions + if (persisted.panes) { + for (const pane of Object.values(persisted.panes)) { + if (pane.status === "working" || pane.status === "permission") { + pane.status = "idle"; + } + } + } + return { ...currentState, ...persisted }; + }, }, ), { name: "TabsStore" }, diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index 9638df6e072..3b7e28310cd 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -1,9 +1,15 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; import type { ChangeCategory } from "shared/changes-types"; -import type { BaseTab, BaseTabsState, Pane, PaneType } from "shared/tabs-types"; +import type { + BaseTab, + BaseTabsState, + Pane, + PaneStatus, + PaneType, +} from "shared/tabs-types"; // Re-export shared types -export type { Pane, PaneType }; +export type { Pane, PaneStatus, PaneType }; /** * A Tab is a container that holds one or more Panes in a Mosaic layout. @@ -73,8 +79,8 @@ export interface TabsStore extends TabsState { removePane: (paneId: string) => void; setFocusedPane: (tabId: string, paneId: string) => void; markPaneAsUsed: (paneId: string) => void; - setNeedsAttention: (paneId: string, needsAttention: boolean) => void; - clearWorkspaceAttention: (workspaceId: string) => void; + setPaneStatus: (paneId: string, status: PaneStatus) => void; + clearWorkspaceAttentionStatus: (workspaceId: string) => void; updatePaneCwd: ( paneId: string, cwd: string | null, diff --git a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts index 0b158830c49..feb42fe3cf4 100644 --- a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts +++ b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts @@ -7,8 +7,29 @@ import { useTabsStore } from "./store"; import { resolveNotificationTarget } from "./utils/resolve-notification-target"; /** - * Hook that listens for notification events via tRPC subscription. - * Handles agent completions and focus requests from native notifications. + * Hook that listens for agent lifecycle events via tRPC subscription and updates + * pane status indicators accordingly. + * + * STATUS MAPPING: + * - Start → "working" (amber pulsing indicator) + * - Stop → "review" (green static) if pane not active, "idle" if active + * - PermissionRequest → "permission" (red pulsing indicator) + * + * KNOWN LIMITATIONS (External - Claude Code / OpenCode hook systems): + * + * 1. User Interrupt (Ctrl+C): Claude Code's Stop hook does NOT fire when the user + * interrupts the agent. The "working" indicator will persist until the next + * Start/Stop event or manual click-to-clear. + * + * 2. Permission Denied: No hook fires when the user denies a permission request. + * The "permission" indicator persists until click-to-clear or next event. + * + * 3. Tool Failures: No hook fires when a tool execution fails. The status + * continues until the agent stops or a new event arrives. + * + * These are fundamental limitations of the external hook systems, not bugs in + * this implementation. Users can always click on the workspace to clear any + * stuck indicator. */ export function useAgentHookListener() { const setActiveWorkspace = useSetActiveWorkspace(); @@ -28,17 +49,36 @@ export function useAgentHookListener() { const { paneId, workspaceId } = target; - if (event.type === NOTIFICATION_EVENTS.AGENT_COMPLETE) { + if (event.type === NOTIFICATION_EVENTS.AGENT_LIFECYCLE) { if (!paneId) return; - const activeTabId = state.activeTabIds[workspaceId]; - const focusedPaneId = activeTabId && state.focusedPaneIds[activeTabId]; - const isAlreadyActive = - activeWorkspaceRef.current?.id === workspaceId && - focusedPaneId === paneId; + const lifecycleEvent = event.data; + if (!lifecycleEvent) return; - if (!isAlreadyActive) { - state.setNeedsAttention(paneId, true); + const { eventType } = lifecycleEvent; + + if (eventType === "Start") { + // Agent started working - always set to working + state.setPaneStatus(paneId, "working"); + } else if (eventType === "PermissionRequest") { + // Agent needs permission - always set to permission (overrides working) + state.setPaneStatus(paneId, "permission"); + } else if (eventType === "Stop") { + // Agent completed - only mark as review if not currently active + const activeTabId = state.activeTabIds[workspaceId]; + const focusedPaneId = + activeTabId && state.focusedPaneIds[activeTabId]; + const isAlreadyActive = + activeWorkspaceRef.current?.id === workspaceId && + focusedPaneId === paneId; + + if (isAlreadyActive) { + // User is watching - go straight to idle + state.setPaneStatus(paneId, "idle"); + } else { + // User not watching - mark for review + state.setPaneStatus(paneId, "review"); + } } } else if (event.type === NOTIFICATION_EVENTS.FOCUS_TAB) { const appState = useAppStore.getState(); diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 1ec904ae1fc..d35968ade46 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -40,7 +40,7 @@ export const CONFIG_TEMPLATE = `{ }`; export const NOTIFICATION_EVENTS = { - AGENT_COMPLETE: "agent-complete", + AGENT_LIFECYCLE: "agent-lifecycle", FOCUS_TAB: "focus-tab", } as const; diff --git a/apps/desktop/src/shared/tabs-types.ts b/apps/desktop/src/shared/tabs-types.ts index d8c921186eb..698f7f08dae 100644 --- a/apps/desktop/src/shared/tabs-types.ts +++ b/apps/desktop/src/shared/tabs-types.ts @@ -10,6 +10,15 @@ import type { ChangeCategory } from "./changes-types"; */ export type PaneType = "terminal" | "webview" | "file-viewer"; +/** + * Pane status for agent lifecycle indicators + * - idle: No indicator shown (default) + * - working: Agent actively processing (amber) + * - permission: Agent blocked, needs user action (red) + * - review: Agent completed, ready for review (green) + */ +export type PaneStatus = "idle" | "working" | "permission" | "review"; + /** * File viewer display modes */ @@ -53,7 +62,7 @@ export interface Pane { type: PaneType; name: string; isNew?: boolean; - needsAttention?: boolean; + status?: PaneStatus; initialCommands?: string[]; initialCwd?: string; url?: string; // For webview panes From c6c924137e0f35f6f0e7cc6c8b64c12ae6baec32 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 10:03:00 +0200 Subject: [PATCH 02/24] fix(desktop): use StatusIndicator in workspace sidebar instead of hardcoded red dot The workspace sidebar was showing a hardcoded red pulsing dot for all attention states instead of using the 3-color StatusIndicator component: - Red (pulsing): needs user input (permission) - Amber (pulsing): agent working - Green (static): ready for review Changes: - Add StatusIndicator import and useMemo for status computation - Compute aggregate workspaceStatus with priority: permission > working > review - Replace hardcoded red indicator in collapsed and expanded views - Separate isUnread indicator (blue) from agent status indicator This fixes the regression where the sidebar always showed red instead of the appropriate status color. --- .../WorkspaceListItem/WorkspaceListItem.tsx | 63 ++++++++++++++----- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 6c32c56fc85..efc06d1725b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -15,7 +15,7 @@ import { Input } from "@superset/ui/input"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { HiMiniXMark } from "react-icons/hi2"; import { LuEye, LuEyeOff, LuFolder, LuFolderGit2 } from "react-icons/lu"; @@ -26,6 +26,8 @@ import { useWorkspaceDeleteHandler, } from "renderer/react-query/workspaces"; import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; +import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; +import type { PaneStatus } from "shared/tabs-types"; import { useCloseWorkspacesList } from "renderer/stores/app-state"; import { useTabsStore } from "renderer/stores/tabs/store"; import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; @@ -115,12 +117,36 @@ export function WorkspaceListItem({ const workspacePaneIds = new Set( workspaceTabs.flatMap((t) => extractPaneIdsFromLayout(t.layout)), ); - const hasPaneAttention = Object.values(panes) - .filter((p) => p != null && workspacePaneIds.has(p.id)) - .some((p) => p.status && p.status !== "idle"); - // Show indicator if workspace is manually marked as unread OR has pane-level attention - const needsAttention = isUnread || hasPaneAttention; + // Compute aggregate status for workspace (priority: permission > working > review) + const workspaceStatus = useMemo((): Exclude | null => { + const workspacePanes = Object.values(panes).filter( + (p) => p != null && workspacePaneIds.has(p.id), + ); + + let hasWorking = false; + let hasReview = false; + + for (const pane of workspacePanes) { + if (!pane.status || pane.status === "idle") continue; + + if (pane.status === "permission") { + return "permission"; // Highest priority, return immediately + } + if (pane.status === "working") { + hasWorking = true; + } else if (pane.status === "review") { + hasReview = true; + } + } + + if (hasWorking) return "working"; + if (hasReview) return "review"; + return null; + }, [panes, workspacePaneIds]); + + // Show indicator if workspace is manually marked as unread OR has pane-level status + const needsAttention = isUnread || workspaceStatus !== null; const handleClick = () => { if (!rename.isRenaming) { @@ -216,11 +242,16 @@ export function WorkspaceListItem({ strokeWidth={STROKE_WIDTH} /> )} - {/* Notification dot */} - {needsAttention && ( + {/* Status indicator */} + {workspaceStatus && ( + + + + )} + {/* Unread dot (only when no status) */} + {isUnread && !workspaceStatus && ( - - + )} @@ -297,7 +328,7 @@ export function WorkspaceListItem({
)} - {/* Icon with notification dot */} + {/* Icon with status indicator */}
@@ -312,10 +343,14 @@ export function WorkspaceListItem({ strokeWidth={STROKE_WIDTH} /> )} - {needsAttention && ( + {workspaceStatus && ( + + + + )} + {isUnread && !workspaceStatus && ( - - + )}
From 0cd38083f437bd59bedf158a4717ec6dc0a4feb1 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 10:37:11 +0200 Subject: [PATCH 03/24] fix(desktop): use correct OpenCode event types for status indicators OpenCode emits session.busy and session.idle as separate event types, not session.status with nested status.type. This fix updates the OpenCode plugin to listen for the correct events, allowing the status indicator to properly transition to green (review) when the agent completes. Breaking change from OpenCode's event structure: - OLD: session.status with status.type === "busy" / "idle" - NEW: session.busy / session.idle as separate event types --- .../main/lib/agent-setup/agent-wrappers.ts | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index 349cdf2895c..54890955bea 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -12,7 +12,7 @@ import { export const WRAPPER_MARKER = "# Superset agent-wrapper v1"; export const CLAUDE_SETTINGS_FILE = "claude-settings.json"; export const OPENCODE_PLUGIN_FILE = "superset-notify.js"; -export const OPENCODE_PLUGIN_MARKER = "// Superset opencode plugin v4"; +export const OPENCODE_PLUGIN_MARKER = "// Superset opencode plugin v5"; const REAL_BINARY_RESOLVER = `find_real_binary() { local name="$1" @@ -145,7 +145,7 @@ export function getOpenCodePluginContent(notifyPath: string): string { " * Superset Notification Plugin for OpenCode", " *", " * This plugin sends desktop notifications when OpenCode sessions need attention.", - " * It hooks into session.status (busy/idle), session.error, and permission.ask events.", + " * It hooks into session.busy, session.idle, session.error, and permission.ask events.", " *", " * IMPORTANT: Subagent/Background Task Filtering", " * --------------------------------------------", @@ -165,8 +165,8 @@ export function getOpenCodePluginContent(notifyPath: string): string { " * @see https://github.com/sst/opencode/blob/dev/packages/app/src/context/notification.tsx", " */", "export const SupersetNotifyPlugin = async ({ $, client }) => {", - " if (globalThis.__supersetOpencodeNotifyPluginV4) return {};", - " globalThis.__supersetOpencodeNotifyPluginV4 = true;", + " if (globalThis.__supersetOpencodeNotifyPluginV5) return {};", + " globalThis.__supersetOpencodeNotifyPluginV5 = true;", "", " // Only run inside a Superset terminal session", " if (!process?.env?.SUPERSET_TAB_ID) return {};", @@ -217,29 +217,25 @@ export function getOpenCodePluginContent(notifyPath: string): string { "", " return {", " event: async ({ event }) => {", - " // Handle session status changes (busy = working, idle = done)", - ' if (event.type === "session.status") {', - " const sessionID = event.properties?.sessionID;", - " const status = event.properties?.status;", + " const sessionID = event.properties?.sessionID;", "", - " // Skip notifications for child/subagent sessions", - " if (await isChildSession(sessionID)) {", - " return;", - " }", + " // Skip notifications for child/subagent sessions", + " if (await isChildSession(sessionID)) {", + " return;", + " }", + "", + " // Handle session busy (agent started working)", + ' if (event.type === "session.busy") {', + ' await notify("Start");', + " }", "", - ' if (status?.type === "busy") {', - ' await notify("Start");', - ' } else if (status?.type === "idle") {', - ' await notify("Stop");', - " }", + " // Handle session idle (agent finished)", + ' if (event.type === "session.idle") {', + ' await notify("Stop");', " }", "", " // Handle session errors (also means session stopped)", ' if (event.type === "session.error") {', - " const sessionID = event.properties?.sessionID;", - " if (await isChildSession(sessionID)) {", - " return;", - " }", ' await notify("Stop");', " }", " },", From 03fe2cefe6aae6a76b149d900a5c0e488a240e2d Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 11:01:53 +0200 Subject: [PATCH 04/24] fix(desktop): clear pane status on terminal exit to fix stuck indicators When an agent (Claude/OpenCode) is quit via Ctrl+C or exit, the Stop hook never fires and the working indicator gets stuck. This fix clears "working" and "permission" status when the terminal process exits, providing a reliable fallback. - Add status clearing in Terminal.tsx exit handlers - "review" status is preserved (user should see completed work) - Update useAgentHookListener docs to reflect the fix --- .../TabsContent/Terminal/Terminal.tsx | 17 +++++++++++++++++ .../stores/tabs/useAgentHookListener.ts | 14 +++++++------- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index bb4d776297e..eedf53a06cc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -50,6 +50,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const updatePaneCwd = useTabsStore((s) => s.updatePaneCwd); const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); + const setPaneStatus = useTabsStore((s) => s.setPaneStatus); const terminalTheme = useTerminalTheme(); // Ref for initial theme to avoid recreating terminal on theme change @@ -223,6 +224,13 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { `\r\n\r\n[Process exited with code ${event.exitCode}]`, ); xtermRef.current.writeln("[Press any key to restart]"); + + // Clear transient pane status on terminal exit + // "working" and "permission" should clear (agent no longer active) + // "review" should persist (user needs to see completed work) + if (pane?.status === "working" || pane?.status === "permission") { + setPaneStatus(paneId, "idle"); + } } }; @@ -306,6 +314,15 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { setSubscriptionEnabled(false); xterm.writeln(`\r\n\r\n[Process exited with code ${event.exitCode}]`); xterm.writeln("[Press any key to restart]"); + + // Clear transient pane status (direct store access since we're in effect) + const currentPane = useTabsStore.getState().panes[paneId]; + if ( + currentPane?.status === "working" || + currentPane?.status === "permission" + ) { + useTabsStore.getState().setPaneStatus(paneId, "idle"); + } } } }; diff --git a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts index feb42fe3cf4..41b737b3eb6 100644 --- a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts +++ b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts @@ -14,22 +14,22 @@ import { resolveNotificationTarget } from "./utils/resolve-notification-target"; * - Start → "working" (amber pulsing indicator) * - Stop → "review" (green static) if pane not active, "idle" if active * - PermissionRequest → "permission" (red pulsing indicator) + * - Terminal Exit → "idle" (handled in Terminal.tsx, clears stuck indicators) * * KNOWN LIMITATIONS (External - Claude Code / OpenCode hook systems): * * 1. User Interrupt (Ctrl+C): Claude Code's Stop hook does NOT fire when the user - * interrupts the agent. The "working" indicator will persist until the next - * Start/Stop event or manual click-to-clear. + * interrupts the agent. However, the terminal exit handler in Terminal.tsx + * will automatically clear the "working" indicator when the process exits. * * 2. Permission Denied: No hook fires when the user denies a permission request. - * The "permission" indicator persists until click-to-clear or next event. + * The terminal exit handler will clear the "permission" indicator on process exit. * * 3. Tool Failures: No hook fires when a tool execution fails. The status - * continues until the agent stops or a new event arrives. + * continues until the agent stops or terminal exits. * - * These are fundamental limitations of the external hook systems, not bugs in - * this implementation. Users can always click on the workspace to clear any - * stuck indicator. + * Note: Terminal exit detection (in Terminal.tsx) provides a reliable fallback + * for clearing stuck indicators when agent hooks fail to fire. */ export function useAgentHookListener() { const setActiveWorkspace = useSetActiveWorkspace(); From a231505d43fb1146f29fb49fda82528534f7edbf Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 11:20:07 +0200 Subject: [PATCH 05/24] fix(desktop): clear status on ESC/Ctrl+C to handle agent interrupts When user presses ESC (Claude Code "stop generating") or Ctrl+C while an agent is working, clear the status immediately. This handles the case where the agent is interrupted but the terminal is still running, so the terminal exit handler doesn't trigger. The oracle recommended this approach as the most robust solution since ESC/Ctrl+C is an explicit user intent to stop the agent. --- .../TabsContent/Terminal/Terminal.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index eedf53a06cc..d0866f4491d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -384,6 +384,23 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { commandBufferRef.current = commandBufferRef.current.slice(0, -1); } else if (domEvent.key === "c" && domEvent.ctrlKey) { commandBufferRef.current = ""; + // Ctrl+C interrupts agent - clear working/permission status + const currentPane = useTabsStore.getState().panes[paneId]; + if ( + currentPane?.status === "working" || + currentPane?.status === "permission" + ) { + useTabsStore.getState().setPaneStatus(paneId, "idle"); + } + } else if (domEvent.key === "Escape") { + // ESC interrupts agent (e.g., Claude Code "stop generating") - clear status + const currentPane = useTabsStore.getState().panes[paneId]; + if ( + currentPane?.status === "working" || + currentPane?.status === "permission" + ) { + useTabsStore.getState().setPaneStatus(paneId, "idle"); + } } else if ( domEvent.key.length === 1 && !domEvent.ctrlKey && From 12a6b11c28c4ac65ad4696285e09cc3ba07421e3 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 11:31:18 +0200 Subject: [PATCH 06/24] fix(desktop): use session.status event for OpenCode busy detection OpenCode doesn't emit a separate 'session.busy' event - the busy state is communicated via 'session.status' with status.type === "busy". The previous fix incorrectly assumed separate events existed. Now we: - Listen to session.status for both busy and idle transitions - Keep session.idle as a deprecated backup for idle detection - Bump plugin version to v6 --- .../main/lib/agent-setup/agent-wrappers.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index 54890955bea..94c7d26e72e 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -12,7 +12,7 @@ import { export const WRAPPER_MARKER = "# Superset agent-wrapper v1"; export const CLAUDE_SETTINGS_FILE = "claude-settings.json"; export const OPENCODE_PLUGIN_FILE = "superset-notify.js"; -export const OPENCODE_PLUGIN_MARKER = "// Superset opencode plugin v5"; +export const OPENCODE_PLUGIN_MARKER = "// Superset opencode plugin v6"; const REAL_BINARY_RESOLVER = `find_real_binary() { local name="$1" @@ -145,7 +145,7 @@ export function getOpenCodePluginContent(notifyPath: string): string { " * Superset Notification Plugin for OpenCode", " *", " * This plugin sends desktop notifications when OpenCode sessions need attention.", - " * It hooks into session.busy, session.idle, session.error, and permission.ask events.", + " * It hooks into session.status (busy/idle), session.idle, session.error, and permission.ask events.", " *", " * IMPORTANT: Subagent/Background Task Filtering", " * --------------------------------------------", @@ -165,8 +165,8 @@ export function getOpenCodePluginContent(notifyPath: string): string { " * @see https://github.com/sst/opencode/blob/dev/packages/app/src/context/notification.tsx", " */", "export const SupersetNotifyPlugin = async ({ $, client }) => {", - " if (globalThis.__supersetOpencodeNotifyPluginV5) return {};", - " globalThis.__supersetOpencodeNotifyPluginV5 = true;", + " if (globalThis.__supersetOpencodeNotifyPluginV6) return {};", + " globalThis.__supersetOpencodeNotifyPluginV6 = true;", "", " // Only run inside a Superset terminal session", " if (!process?.env?.SUPERSET_TAB_ID) return {};", @@ -224,12 +224,18 @@ export function getOpenCodePluginContent(notifyPath: string): string { " return;", " }", "", - " // Handle session busy (agent started working)", - ' if (event.type === "session.busy") {', - ' await notify("Start");', + " // Handle session status changes (busy/idle/retry)", + " // This is the primary event for status transitions", + ' if (event.type === "session.status") {', + " const status = event.properties?.status;", + ' if (status?.type === "busy") {', + ' await notify("Start");', + ' } else if (status?.type === "idle") {', + ' await notify("Stop");', + " }", " }", "", - " // Handle session idle (agent finished)", + " // Handle deprecated session.idle event (backwards compatibility)", ' if (event.type === "session.idle") {', ' await notify("Stop");', " }", From 12688e0b7343ff292932e798b30e108d78eb3193 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 11:55:48 +0200 Subject: [PATCH 07/24] chore(desktop): add debug logging to OpenCode plugin for idle detection Bumped to v7. Added console.log statements to understand what events OpenCode emits when transitioning between busy and idle states. Will be removed once the issue is diagnosed. --- .../src/main/lib/agent-setup/agent-wrappers.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index 94c7d26e72e..f5a7002e8b7 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -12,7 +12,7 @@ import { export const WRAPPER_MARKER = "# Superset agent-wrapper v1"; export const CLAUDE_SETTINGS_FILE = "claude-settings.json"; export const OPENCODE_PLUGIN_FILE = "superset-notify.js"; -export const OPENCODE_PLUGIN_MARKER = "// Superset opencode plugin v6"; +export const OPENCODE_PLUGIN_MARKER = "// Superset opencode plugin v7"; const REAL_BINARY_RESOLVER = `find_real_binary() { local name="$1" @@ -165,8 +165,8 @@ export function getOpenCodePluginContent(notifyPath: string): string { " * @see https://github.com/sst/opencode/blob/dev/packages/app/src/context/notification.tsx", " */", "export const SupersetNotifyPlugin = async ({ $, client }) => {", - " if (globalThis.__supersetOpencodeNotifyPluginV6) return {};", - " globalThis.__supersetOpencodeNotifyPluginV6 = true;", + " if (globalThis.__supersetOpencodeNotifyPluginV7) return {};", + " globalThis.__supersetOpencodeNotifyPluginV7 = true;", "", " // Only run inside a Superset terminal session", " if (!process?.env?.SUPERSET_TAB_ID) return {};", @@ -219,8 +219,14 @@ export function getOpenCodePluginContent(notifyPath: string): string { " event: async ({ event }) => {", " const sessionID = event.properties?.sessionID;", "", + " // Debug: log all session events to understand what OpenCode emits", + ' if (event.type?.startsWith("session.")) {', + ' console.log("[superset-plugin] Event:", event.type, "props:", JSON.stringify(event.properties));', + " }", + "", " // Skip notifications for child/subagent sessions", " if (await isChildSession(sessionID)) {", + ' console.log("[superset-plugin] Skipping child session:", sessionID);', " return;", " }", "", @@ -228,20 +234,25 @@ export function getOpenCodePluginContent(notifyPath: string): string { " // This is the primary event for status transitions", ' if (event.type === "session.status") {', " const status = event.properties?.status;", + ' console.log("[superset-plugin] session.status - status object:", JSON.stringify(status));', ' if (status?.type === "busy") {', + ' console.log("[superset-plugin] Detected BUSY, sending Start");', ' await notify("Start");', ' } else if (status?.type === "idle") {', + ' console.log("[superset-plugin] Detected IDLE, sending Stop");', ' await notify("Stop");', " }", " }", "", " // Handle deprecated session.idle event (backwards compatibility)", ' if (event.type === "session.idle") {', + ' console.log("[superset-plugin] Detected session.idle event, sending Stop");', ' await notify("Stop");', " }", "", " // Handle session errors (also means session stopped)", ' if (event.type === "session.error") {', + ' console.log("[superset-plugin] Detected session.error, sending Stop");', ' await notify("Stop");', " }", " },", From 43c908d55cf2c8e94b3f224b5e7b441a036c2297 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 12:01:02 +0200 Subject: [PATCH 08/24] chore(desktop): add debug logging to trace OpenCode Stop notification flow Adding temporary logs to trace: 1. Server: notification received 2. Renderer: event received by listener 3. Stop handler: isAlreadyActive check result Will help diagnose why green indicator doesn't show after OpenCode completion. --- .../main/lib/agent-setup/agent-wrappers.ts | 11 --- .../src/main/lib/notifications/server.ts | 9 +++ .../stores/tabs/useAgentHookListener.ts | 17 +++++ .../CONTINUITY_CLAUDE-rebase-to-main.md | 75 +++++++++++++++++++ .../auto-handoff-2026-01-06T07-39-46.md | 29 +++++++ .../auto-handoff-2026-01-06T08-28-24.md | 28 +++++++ .../auto-handoff-2026-01-06T09-54-24.md | 28 +++++++ 7 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md create mode 100644 thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T07-39-46.md create mode 100644 thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T08-28-24.md create mode 100644 thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T09-54-24.md diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index f5a7002e8b7..1417b091d16 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -219,14 +219,8 @@ export function getOpenCodePluginContent(notifyPath: string): string { " event: async ({ event }) => {", " const sessionID = event.properties?.sessionID;", "", - " // Debug: log all session events to understand what OpenCode emits", - ' if (event.type?.startsWith("session.")) {', - ' console.log("[superset-plugin] Event:", event.type, "props:", JSON.stringify(event.properties));', - " }", - "", " // Skip notifications for child/subagent sessions", " if (await isChildSession(sessionID)) {", - ' console.log("[superset-plugin] Skipping child session:", sessionID);', " return;", " }", "", @@ -234,25 +228,20 @@ export function getOpenCodePluginContent(notifyPath: string): string { " // This is the primary event for status transitions", ' if (event.type === "session.status") {', " const status = event.properties?.status;", - ' console.log("[superset-plugin] session.status - status object:", JSON.stringify(status));', ' if (status?.type === "busy") {', - ' console.log("[superset-plugin] Detected BUSY, sending Start");', ' await notify("Start");', ' } else if (status?.type === "idle") {', - ' console.log("[superset-plugin] Detected IDLE, sending Stop");', ' await notify("Stop");', " }", " }", "", " // Handle deprecated session.idle event (backwards compatibility)", ' if (event.type === "session.idle") {', - ' console.log("[superset-plugin] Detected session.idle event, sending Stop");', ' await notify("Stop");', " }", "", " // Handle session errors (also means session stopped)", ' if (event.type === "session.error") {', - ' console.log("[superset-plugin] Detected session.error, sending Stop");', ' await notify("Stop");', " }", " },", diff --git a/apps/desktop/src/main/lib/notifications/server.ts b/apps/desktop/src/main/lib/notifications/server.ts index 0a453dfd612..3f759bcd0aa 100644 --- a/apps/desktop/src/main/lib/notifications/server.ts +++ b/apps/desktop/src/main/lib/notifications/server.ts @@ -147,6 +147,15 @@ app.get("/hook/complete", (req, res) => { const mappedEventType = mapEventType(eventType as string | undefined); + // DEBUG: Log all incoming hook requests + console.log("[notifications] Received hook:", { + eventType, + mappedEventType, + paneId, + tabId, + workspaceId, + }); + // Unknown or missing eventType: return success but don't process // This ensures forward compatibility and doesn't block the agent if (!mappedEventType) { diff --git a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts index 41b737b3eb6..e7f89bae3f2 100644 --- a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts +++ b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts @@ -50,6 +50,14 @@ export function useAgentHookListener() { const { paneId, workspaceId } = target; if (event.type === NOTIFICATION_EVENTS.AGENT_LIFECYCLE) { + // DEBUG: Log incoming lifecycle events + console.log("[useAgentHookListener] Received:", { + eventType: event.data?.eventType, + paneId, + workspaceId, + activeWorkspace: activeWorkspaceRef.current?.id, + }); + if (!paneId) return; const lifecycleEvent = event.data; @@ -72,6 +80,15 @@ export function useAgentHookListener() { activeWorkspaceRef.current?.id === workspaceId && focusedPaneId === paneId; + // DEBUG: Log Stop handling + console.log("[useAgentHookListener] Stop event:", { + isAlreadyActive, + activeTabId, + focusedPaneId, + paneId, + willSetTo: isAlreadyActive ? "idle" : "review", + }); + if (isAlreadyActive) { // User is watching - go straight to idle state.setPaneStatus(paneId, "idle"); diff --git a/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md b/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md new file mode 100644 index 00000000000..2aec3beefaa --- /dev/null +++ b/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md @@ -0,0 +1,75 @@ +--- +created: 2026-01-06T12:00:00Z +last_updated: 2026-01-06T16:15:00Z +session_count: 3 +status: IN_PROGRESS +--- + +# Session: Working Indicator Feature + +## Goal +Ship 3-color workspace status indicators on main branch, with proper OpenCode support. + +## Completed Work + +### 1. Rebase onto main (DONE) +- Cherry-picked `c3a67201` onto main (was on persistent-terminals) +- Resolved 8 conflicts, dropped persistent-terminals code +- Pushed as commit `a9cd65d4` + +### 2. Fix sidebar StatusIndicator regression (DONE) +- WorkspaceListItem.tsx had hardcoded red dot instead of 3-color StatusIndicator +- Added `workspaceStatus` computation with priority: permission > working > review +- Replaced hardcoded red with StatusIndicator component +- Pushed as commit `6e4a8d0e` + +### 3. Updated PR #588 (DONE) +- Changed base branch from persistent-terminals → main +- Updated description with UI locations, QA checklist, indicator colors + +### 4. Fix OpenCode completion detection (DONE) +**Root cause found:** OpenCode uses `session.busy` and `session.idle` as separate event types, NOT `session.status` with nested `status.type`. + +**Fix applied:** +- OLD: `session.status` with `status.type === "busy"` / `"idle"` +- NEW: `session.busy` / `session.idle` as separate events +- Bumped plugin version to v5 +- Committed as `649258f7` + +### 5. Fix stuck indicator on agent quit (DONE) +**Problem:** When agent is quit via Ctrl+C or exit, Stop hook never fires and indicator stays amber forever. + +**Root cause:** Terminal exit events were disconnected from pane status cleanup. + +**Solution:** Clear "working" and "permission" status when terminal exits. +- Added `setPaneStatus` selector to Terminal.tsx +- Added status clearing in both exit handlers (handleStreamData and flushPendingEvents) +- "review" status preserved (user should see completed work) +- Updated useAgentHookListener.ts documentation +- Committed as `60c36400` + +## Current Work + +### Ready for QA (IN PROGRESS) +All fixes applied, needs manual testing: +1. OpenCode completion detection +2. Stuck indicator on quit + +## Next Steps +- [ ] Manual test with Claude and OpenCode +- [ ] Get PR merged + +## Key Files +- `apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts` - OpenCode plugin (FIXED) +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx` - Terminal exit cleanup (FIXED) +- `apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts` - Agent lifecycle docs (UPDATED) + +## Working Set +- Branch: `working-indicator` +- PR: https://github.com/superset-sh/superset/pull/588 +- Latest commit: `60c36400 fix(desktop): clear pane status on terminal exit to fix stuck indicators` + +## Key Learnings +- OpenCode emits `session.busy` and `session.idle` as separate events (not nested in `session.status`) +- Terminal exit events (from node-pty) are reliable and always fire +- Connecting terminal exit to status cleanup provides a robust fallback for stuck indicators diff --git a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T07-39-46.md b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T07-39-46.md new file mode 100644 index 00000000000..6f4fa351fea --- /dev/null +++ b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T07-39-46.md @@ -0,0 +1,29 @@ +--- +type: auto-handoff +date: 2026-01-06T07:39:46.734Z +session_name: rebase-to-main +trigger: pre-compact +--- + +# Auto-Handoff: [→] Cherry-pick and resolve conflicts + +This handoff was automatically created before context compaction. + +## In Progress + +[→] Cherry-pick and resolve conflicts + +## Recent Completed + +[x] Identified all 8 conflicts for cherry-pick +- Done: [x] Analyzed conflict types (6 content, 2 deleted-by-us) + +## Resume Instructions + +1. Read this handoff to understand current state +2. Check the ledger for full context: thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md +3. Continue from where you left off + +## Source Ledger + +/Users/andreasasprou/.superset/worktrees/superset/workingindicator/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md diff --git a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T08-28-24.md b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T08-28-24.md new file mode 100644 index 00000000000..7ad729d91da --- /dev/null +++ b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T08-28-24.md @@ -0,0 +1,28 @@ +--- +type: auto-handoff +date: 2026-01-06T08:28:24.660Z +session_name: rebase-to-main +trigger: pre-compact +--- + +# Auto-Handoff: Unknown + +This handoff was automatically created before context compaction. + +## In Progress + +Unknown + +## Recent Completed + +None tracked + +## Resume Instructions + +1. Read this handoff to understand current state +2. Check the ledger for full context: thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md +3. Continue from where you left off + +## Source Ledger + +/Users/andreasasprou/.superset/worktrees/superset/workingindicator/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md diff --git a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T09-54-24.md b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T09-54-24.md new file mode 100644 index 00000000000..eca326e7a82 --- /dev/null +++ b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T09-54-24.md @@ -0,0 +1,28 @@ +--- +type: auto-handoff +date: 2026-01-06T09:54:24.216Z +session_name: rebase-to-main +trigger: pre-compact +--- + +# Auto-Handoff: Unknown + +This handoff was automatically created before context compaction. + +## In Progress + +Unknown + +## Recent Completed + +None tracked + +## Resume Instructions + +1. Read this handoff to understand current state +2. Check the ledger for full context: thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md +3. Continue from where you left off + +## Source Ledger + +/Users/andreasasprou/.superset/worktrees/superset/workingindicator/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md From d542032af09a24792a4d037e40cecc30b8ff2b14 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 12:04:35 +0200 Subject: [PATCH 09/24] feat(desktop): add debug logging utility with SUPERSET_DEBUG flag Added shared/debug.ts with debugLog() function that only logs when SUPERSET_DEBUG=1 is set. Replaced hardcoded console.log statements in notification server and agent hook listener with debugLog calls. Enable debug mode: SUPERSET_DEBUG=1 bun run desktop Or add to .env: SUPERSET_DEBUG=1 --- .../src/main/lib/notifications/server.ts | 4 +- .../stores/tabs/useAgentHookListener.ts | 7 ++-- apps/desktop/src/shared/debug.ts | 37 +++++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/shared/debug.ts diff --git a/apps/desktop/src/main/lib/notifications/server.ts b/apps/desktop/src/main/lib/notifications/server.ts index 3f759bcd0aa..8a47e93af91 100644 --- a/apps/desktop/src/main/lib/notifications/server.ts +++ b/apps/desktop/src/main/lib/notifications/server.ts @@ -1,6 +1,7 @@ import { EventEmitter } from "node:events"; import express from "express"; import { NOTIFICATION_EVENTS } from "shared/constants"; +import { debugLog } from "shared/debug"; import { env } from "shared/env.shared"; import { appState } from "../app-state"; import { HOOK_PROTOCOL_VERSION } from "../terminal/env"; @@ -147,8 +148,7 @@ app.get("/hook/complete", (req, res) => { const mappedEventType = mapEventType(eventType as string | undefined); - // DEBUG: Log all incoming hook requests - console.log("[notifications] Received hook:", { + debugLog("notifications", "Received hook:", { eventType, mappedEventType, paneId, diff --git a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts index e7f89bae3f2..19e44b17ad2 100644 --- a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts +++ b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts @@ -2,6 +2,7 @@ import { useRef } from "react"; import { trpc } from "renderer/lib/trpc"; import { useSetActiveWorkspace } from "renderer/react-query/workspaces/useSetActiveWorkspace"; import { NOTIFICATION_EVENTS } from "shared/constants"; +import { debugLog } from "shared/debug"; import { useAppStore } from "../app-state"; import { useTabsStore } from "./store"; import { resolveNotificationTarget } from "./utils/resolve-notification-target"; @@ -50,8 +51,7 @@ export function useAgentHookListener() { const { paneId, workspaceId } = target; if (event.type === NOTIFICATION_EVENTS.AGENT_LIFECYCLE) { - // DEBUG: Log incoming lifecycle events - console.log("[useAgentHookListener] Received:", { + debugLog("agent-hooks", "Received:", { eventType: event.data?.eventType, paneId, workspaceId, @@ -80,8 +80,7 @@ export function useAgentHookListener() { activeWorkspaceRef.current?.id === workspaceId && focusedPaneId === paneId; - // DEBUG: Log Stop handling - console.log("[useAgentHookListener] Stop event:", { + debugLog("agent-hooks", "Stop event:", { isAlreadyActive, activeTabId, focusedPaneId, diff --git a/apps/desktop/src/shared/debug.ts b/apps/desktop/src/shared/debug.ts new file mode 100644 index 00000000000..6778e452dd8 --- /dev/null +++ b/apps/desktop/src/shared/debug.ts @@ -0,0 +1,37 @@ +/** + * Debug logging utility for development and QA. + * + * Enable debug logs by setting environment variable: + * SUPERSET_DEBUG=1 bun run desktop + * + * Or in .env: + * SUPERSET_DEBUG=1 + * + * Usage: + * import { debugLog } from "shared/debug"; + * debugLog("notifications", "Received hook:", data); + * // Logs: [debug:notifications] Received hook: {...} + */ + +const isDebugEnabled = + typeof process !== "undefined" && + (process.env.SUPERSET_DEBUG === "1" || process.env.SUPERSET_DEBUG === "true"); + +/** + * Log a debug message if SUPERSET_DEBUG is enabled. + * + * @param namespace - Category for the log (e.g., "notifications", "agent-hooks") + * @param args - Values to log (same as console.log) + */ +export function debugLog(namespace: string, ...args: unknown[]): void { + if (isDebugEnabled) { + console.log(`[debug:${namespace}]`, ...args); + } +} + +/** + * Check if debug mode is enabled. + */ +export function isDebug(): boolean { + return isDebugEnabled; +} From f3f5dc9d1bd41af9d24ddeee44ce99c45867129a Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 12:11:02 +0200 Subject: [PATCH 10/24] fix(desktop): remove unused needsAttention variable --- .../WorkspaceListItem/WorkspaceListItem.tsx | 7 ++----- .../ContentView/TabsContent/GroupStrip/GroupItem.tsx | 4 +--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index efc06d1725b..8f2afcbba8b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -25,12 +25,12 @@ import { useSetActiveWorkspace, useWorkspaceDeleteHandler, } from "renderer/react-query/workspaces"; -import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; -import type { PaneStatus } from "shared/tabs-types"; +import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; import { useCloseWorkspacesList } from "renderer/stores/app-state"; import { useTabsStore } from "renderer/stores/tabs/store"; import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; +import type { PaneStatus } from "shared/tabs-types"; import { STROKE_WIDTH } from "../constants"; import { BranchSwitcher, @@ -145,9 +145,6 @@ export function WorkspaceListItem({ return null; }, [panes, workspacePaneIds]); - // Show indicator if workspace is manually marked as unread OR has pane-level status - const needsAttention = isUnread || workspaceStatus !== null; - const handleClick = () => { if (!rename.isRenaming) { setActiveWorkspace.mutate({ id }); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx index f9d35e6b6d0..5ba28f7871a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx @@ -40,9 +40,7 @@ export function GroupItem({ {displayName} - {status && status !== "idle" && ( - - )} + {status && status !== "idle" && }
From 0906d4c43eeed14b662946faa7486185684978a3 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 12:25:45 +0200 Subject: [PATCH 11/24] fix(desktop): harden OpenCode plugin against race conditions (v8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Oracle's recommendations for robustness: 1. **Safe defaults on error**: isChildSession now returns true (skip) on error instead of false. This prevents race conditions where failed lookups cause child session events to be treated as root events. 2. **Session-scoping**: Tracks the root sessionID when first busy event arrives. Subsequent events from other sessions are ignored, preventing child session events from interfering with root session state. 3. **State deduplication**: Tracks currentState (idle/busy) and stopSent flag. Only sends Start on idle→busy transition, only sends Stop once per busy period. Prevents duplicate/out-of-order notifications. 4. **Caching**: Caches isChildSession lookups to avoid repeated async calls for the same session. 5. **Debug logging**: When SUPERSET_DEBUG=1 is set, logs all events, state transitions, and notification attempts for troubleshooting. These changes address intermittent failures where the green "finished" indicator wouldn't appear after OpenCode completion. --- .../main/lib/agent-setup/agent-wrappers.ts | 141 +++++++++++++----- 1 file changed, 104 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index 1417b091d16..c0627241214 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -12,7 +12,7 @@ import { export const WRAPPER_MARKER = "# Superset agent-wrapper v1"; export const CLAUDE_SETTINGS_FILE = "claude-settings.json"; export const OPENCODE_PLUGIN_FILE = "superset-notify.js"; -export const OPENCODE_PLUGIN_MARKER = "// Superset opencode plugin v7"; +export const OPENCODE_PLUGIN_MARKER = "// Superset opencode plugin v8"; const REAL_BINARY_RESOLVER = `find_real_binary() { local name="$1" @@ -147,31 +147,41 @@ export function getOpenCodePluginContent(notifyPath: string): string { " * This plugin sends desktop notifications when OpenCode sessions need attention.", " * It hooks into session.status (busy/idle), session.idle, session.error, and permission.ask events.", " *", - " * IMPORTANT: Subagent/Background Task Filtering", - " * --------------------------------------------", + " * ROBUSTNESS FEATURES (v8):", + " * - Session-scoped: Tracks root sessionID, ignores events from other sessions", + " * - Deduplication: Only sends Start on idle→busy, Stop on busy→idle transitions", + " * - Safe defaults: On error, assumes child session to avoid false positives", + " * - Debug logging: Set SUPERSET_DEBUG=1 to enable verbose logging", + " *", + " * SUBAGENT FILTERING:", " * When using oh-my-opencode or similar tools that spawn background subagents", " * (e.g., explore, librarian, oracle agents), each subagent runs in its own", " * OpenCode session. These child sessions emit session.idle events when they", " * complete, which would cause excessive notifications if not filtered.", " *", - " * How we detect child sessions:", - " * - OpenCode sessions have a `parentID` field when they are subagent sessions", - " * - Main/root sessions have `parentID` as undefined", - " * - We use client.session.list() to look up the session and check parentID", - " *", - " * Reference: OpenCode's own notification handling in packages/app/src/context/notification.tsx", - " * uses the same approach to filter out child session notifications.", + " * We detect child sessions by checking the `parentID` field - main/root sessions", + " * have `parentID` as undefined, while child sessions have it set.", " *", " * @see https://github.com/sst/opencode/blob/dev/packages/app/src/context/notification.tsx", " */", "export const SupersetNotifyPlugin = async ({ $, client }) => {", - " if (globalThis.__supersetOpencodeNotifyPluginV7) return {};", - " globalThis.__supersetOpencodeNotifyPluginV7 = true;", + " if (globalThis.__supersetOpencodeNotifyPluginV8) return {};", + " globalThis.__supersetOpencodeNotifyPluginV8 = true;", "", " // Only run inside a Superset terminal session", " if (!process?.env?.SUPERSET_TAB_ID) return {};", "", ` const notifyPath = "${notifyPath}";`, + " const debug = process?.env?.SUPERSET_DEBUG === '1';", + "", + " // State tracking for deduplication and session-scoping", + " let currentState = 'idle'; // 'idle' | 'busy'", + " let rootSessionID = null; // The session we're tracking (first busy session)", + " let stopSent = false; // Prevent duplicate Stop notifications", + "", + " const log = (...args) => {", + " if (debug) console.log('[superset-plugin]', ...args);", + " };", "", " /**", " * Sends a notification to Superset's notification server.", @@ -179,70 +189,127 @@ export function getOpenCodePluginContent(notifyPath: string): string { " */", " const notify = async (hookEventName) => {", " const payload = JSON.stringify({ hook_event_name: hookEventName });", + " log('Sending notification:', hookEventName);", " try {", shellLine, - " } catch {", - " // Best-effort only; do not break the agent if notification fails", + " log('Notification sent successfully');", + " } catch (err) {", + " log('Notification failed:', err?.message || err);", " }", " };", "", " /**", " * Checks if a session is a child/subagent session by looking up its parentID.", + " * Uses caching to avoid repeated lookups for the same session.", " *", - " * Background: When oh-my-opencode spawns background agents (explore, librarian, etc.),", - " * each agent runs in a separate OpenCode session with a parentID pointing to the", - " * main session. We only want to notify for main sessions, not subagent completions.", - " *", - " * Implementation notes:", - " * - Uses client.session.list() because it reliably returns parentID", - " * - session.get() has parameter issues in some SDK versions", - " * - This is a local RPC call (~10ms), acceptable for infrequent notification events", - " * - On error, returns false (assumes main session) to avoid missing notifications", - " *", - " * @param sessionID - The session ID from the event", - " * @returns true if this is a child/subagent session, false if main session", + " * IMPORTANT: On error, returns TRUE (assumes child) to avoid false positives.", + " * This prevents race conditions where a failed lookup causes child session", + " * events to be treated as root session events.", " */", + " const childSessionCache = new Map();", " const isChildSession = async (sessionID) => {", - " if (!sessionID || !client?.session?.list) return false;", + " if (!sessionID) return true; // No sessionID = can't verify, skip", + " if (!client?.session?.list) return true; // Can't check, skip", + "", + " // Check cache first", + " if (childSessionCache.has(sessionID)) {", + " return childSessionCache.get(sessionID);", + " }", + "", " try {", " const sessions = await client.session.list();", " const session = sessions.data?.find((s) => s.id === sessionID);", - " // Sessions with parentID are child/subagent sessions", - " return !!session?.parentID;", - " } catch {", - " // On error, assume it's a main session to avoid missing notifications", - " return false;", + " const isChild = !!session?.parentID;", + " childSessionCache.set(sessionID, isChild);", + " log('Session lookup:', sessionID, 'isChild:', isChild);", + " return isChild;", + " } catch (err) {", + " log('Session lookup failed:', err?.message || err, '- assuming child');", + " // On error, assume child session to avoid false positives", + " // This prevents race conditions where failures cause incorrect notifications", + " return true;", + " }", + " };", + "", + " /**", + " * Handles state transition to busy.", + " * Only sends Start if transitioning from idle and session matches root.", + " */", + " const handleBusy = async (sessionID) => {", + " // If we don't have a root session yet, this becomes our root", + " if (!rootSessionID) {", + " rootSessionID = sessionID;", + " log('Root session set:', rootSessionID);", + " }", + "", + " // Only process events for our root session", + " if (sessionID !== rootSessionID) {", + " log('Ignoring busy from non-root session:', sessionID);", + " return;", + " }", + "", + " // Only send Start if transitioning from idle", + " if (currentState === 'idle') {", + " currentState = 'busy';", + " stopSent = false; // Reset stop flag for new busy period", + " await notify('Start');", + " } else {", + " log('Already busy, skipping Start');", + " }", + " };", + "", + " /**", + " * Handles state transition to idle/stopped.", + " * Only sends Stop once per busy period and only for root session.", + " */", + " const handleStop = async (sessionID, reason) => {", + " // Only process events for our root session (if we have one)", + " if (rootSessionID && sessionID !== rootSessionID) {", + " log('Ignoring stop from non-root session:', sessionID, 'reason:', reason);", + " return;", + " }", + "", + " // Only send Stop if we're busy and haven't already sent Stop", + " if (currentState === 'busy' && !stopSent) {", + " currentState = 'idle';", + " stopSent = true;", + " log('Stopping, reason:', reason);", + " await notify('Stop');", + " } else {", + " log('Skipping Stop - state:', currentState, 'stopSent:', stopSent, 'reason:', reason);", " }", " };", "", " return {", " event: async ({ event }) => {", " const sessionID = event.properties?.sessionID;", + " log('Event:', event.type, 'sessionID:', sessionID);", "", " // Skip notifications for child/subagent sessions", " if (await isChildSession(sessionID)) {", + " log('Skipping child session');", " return;", " }", "", " // Handle session status changes (busy/idle/retry)", - " // This is the primary event for status transitions", ' if (event.type === "session.status") {', " const status = event.properties?.status;", + " log('Status:', status?.type);", ' if (status?.type === "busy") {', - ' await notify("Start");', + " await handleBusy(sessionID);", ' } else if (status?.type === "idle") {', - ' await notify("Stop");', + " await handleStop(sessionID, 'session.status.idle');", " }", " }", "", " // Handle deprecated session.idle event (backwards compatibility)", ' if (event.type === "session.idle") {', - ' await notify("Stop");', + " await handleStop(sessionID, 'session.idle');", " }", "", " // Handle session errors (also means session stopped)", ' if (event.type === "session.error") {', - ' await notify("Stop");', + " await handleStop(sessionID, 'session.error');", " }", " },", ' "permission.ask": async (_permission, output) => {', From 8fe8b1551970639c36f8a835ea8f2c1a51b445df Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 12:32:07 +0200 Subject: [PATCH 12/24] chore: remove thoughts/ directory from PR (internal artifacts) --- .../CONTINUITY_CLAUDE-rebase-to-main.md | 75 ------------------- .../auto-handoff-2026-01-06T07-39-46.md | 29 ------- .../auto-handoff-2026-01-06T08-28-24.md | 28 ------- .../auto-handoff-2026-01-06T09-54-24.md | 28 ------- 4 files changed, 160 deletions(-) delete mode 100644 thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md delete mode 100644 thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T07-39-46.md delete mode 100644 thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T08-28-24.md delete mode 100644 thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T09-54-24.md diff --git a/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md b/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md deleted file mode 100644 index 2aec3beefaa..00000000000 --- a/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -created: 2026-01-06T12:00:00Z -last_updated: 2026-01-06T16:15:00Z -session_count: 3 -status: IN_PROGRESS ---- - -# Session: Working Indicator Feature - -## Goal -Ship 3-color workspace status indicators on main branch, with proper OpenCode support. - -## Completed Work - -### 1. Rebase onto main (DONE) -- Cherry-picked `c3a67201` onto main (was on persistent-terminals) -- Resolved 8 conflicts, dropped persistent-terminals code -- Pushed as commit `a9cd65d4` - -### 2. Fix sidebar StatusIndicator regression (DONE) -- WorkspaceListItem.tsx had hardcoded red dot instead of 3-color StatusIndicator -- Added `workspaceStatus` computation with priority: permission > working > review -- Replaced hardcoded red with StatusIndicator component -- Pushed as commit `6e4a8d0e` - -### 3. Updated PR #588 (DONE) -- Changed base branch from persistent-terminals → main -- Updated description with UI locations, QA checklist, indicator colors - -### 4. Fix OpenCode completion detection (DONE) -**Root cause found:** OpenCode uses `session.busy` and `session.idle` as separate event types, NOT `session.status` with nested `status.type`. - -**Fix applied:** -- OLD: `session.status` with `status.type === "busy"` / `"idle"` -- NEW: `session.busy` / `session.idle` as separate events -- Bumped plugin version to v5 -- Committed as `649258f7` - -### 5. Fix stuck indicator on agent quit (DONE) -**Problem:** When agent is quit via Ctrl+C or exit, Stop hook never fires and indicator stays amber forever. - -**Root cause:** Terminal exit events were disconnected from pane status cleanup. - -**Solution:** Clear "working" and "permission" status when terminal exits. -- Added `setPaneStatus` selector to Terminal.tsx -- Added status clearing in both exit handlers (handleStreamData and flushPendingEvents) -- "review" status preserved (user should see completed work) -- Updated useAgentHookListener.ts documentation -- Committed as `60c36400` - -## Current Work - -### Ready for QA (IN PROGRESS) -All fixes applied, needs manual testing: -1. OpenCode completion detection -2. Stuck indicator on quit - -## Next Steps -- [ ] Manual test with Claude and OpenCode -- [ ] Get PR merged - -## Key Files -- `apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts` - OpenCode plugin (FIXED) -- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx` - Terminal exit cleanup (FIXED) -- `apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts` - Agent lifecycle docs (UPDATED) - -## Working Set -- Branch: `working-indicator` -- PR: https://github.com/superset-sh/superset/pull/588 -- Latest commit: `60c36400 fix(desktop): clear pane status on terminal exit to fix stuck indicators` - -## Key Learnings -- OpenCode emits `session.busy` and `session.idle` as separate events (not nested in `session.status`) -- Terminal exit events (from node-pty) are reliable and always fire -- Connecting terminal exit to status cleanup provides a robust fallback for stuck indicators diff --git a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T07-39-46.md b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T07-39-46.md deleted file mode 100644 index 6f4fa351fea..00000000000 --- a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T07-39-46.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -type: auto-handoff -date: 2026-01-06T07:39:46.734Z -session_name: rebase-to-main -trigger: pre-compact ---- - -# Auto-Handoff: [→] Cherry-pick and resolve conflicts - -This handoff was automatically created before context compaction. - -## In Progress - -[→] Cherry-pick and resolve conflicts - -## Recent Completed - -[x] Identified all 8 conflicts for cherry-pick -- Done: [x] Analyzed conflict types (6 content, 2 deleted-by-us) - -## Resume Instructions - -1. Read this handoff to understand current state -2. Check the ledger for full context: thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md -3. Continue from where you left off - -## Source Ledger - -/Users/andreasasprou/.superset/worktrees/superset/workingindicator/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md diff --git a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T08-28-24.md b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T08-28-24.md deleted file mode 100644 index 7ad729d91da..00000000000 --- a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T08-28-24.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -type: auto-handoff -date: 2026-01-06T08:28:24.660Z -session_name: rebase-to-main -trigger: pre-compact ---- - -# Auto-Handoff: Unknown - -This handoff was automatically created before context compaction. - -## In Progress - -Unknown - -## Recent Completed - -None tracked - -## Resume Instructions - -1. Read this handoff to understand current state -2. Check the ledger for full context: thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md -3. Continue from where you left off - -## Source Ledger - -/Users/andreasasprou/.superset/worktrees/superset/workingindicator/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md diff --git a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T09-54-24.md b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T09-54-24.md deleted file mode 100644 index eca326e7a82..00000000000 --- a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T09-54-24.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -type: auto-handoff -date: 2026-01-06T09:54:24.216Z -session_name: rebase-to-main -trigger: pre-compact ---- - -# Auto-Handoff: Unknown - -This handoff was automatically created before context compaction. - -## In Progress - -Unknown - -## Recent Completed - -None tracked - -## Resume Instructions - -1. Read this handoff to understand current state -2. Check the ledger for full context: thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md -3. Continue from where you left off - -## Source Ledger - -/Users/andreasasprou/.superset/worktrees/superset/workingindicator/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md From 765edf1a105b0b55ef9a4b508a735f13166f03e5 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 12:33:57 +0200 Subject: [PATCH 13/24] fix(desktop): address PR review feedback P0: Remove thoughts/ directory and add to .gitignore P1: Memoize workspacePaneIds in WorkspaceListItem - workspacePaneIds now properly memoized with useMemo - Uses direct pane lookup by ID for O(workspacePanes) instead of O(totalPanes) - Prevents sidebar jank with many workspaces/panes P1: Add session.busy fallback for OpenCode version compatibility - Now handles both session.status with nested type AND separate session.busy/session.idle events for different OpenCode versions P2: Move NotificationIds type to shared/ - Prevents renderer importing from main/ (boundary violation) - Re-exports from server.ts for backwards compatibility --- .gitignore | 4 +++- .../main/lib/agent-setup/agent-wrappers.ts | 6 ++++- .../src/main/lib/notifications/server.ts | 17 ++++++-------- .../WorkspaceListItem/WorkspaceListItem.tsx | 22 +++++++++---------- .../tabs/utils/resolve-notification-target.ts | 2 +- apps/desktop/src/shared/notification-types.ts | 14 ++++++++++++ 6 files changed, 41 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/src/shared/notification-types.ts diff --git a/.gitignore b/.gitignore index dc6e870d9f6..8e1224ea24a 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,6 @@ next-env.d.ts .env # Reference material downloaded for agents -examples \ No newline at end of file +examples +# Internal artifacts +thoughts/ diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index c0627241214..3110b09847d 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -302,7 +302,11 @@ export function getOpenCodePluginContent(notifyPath: string): string { " }", " }", "", - " // Handle deprecated session.idle event (backwards compatibility)", + " // Handle deprecated/alternative event types (backwards compatibility)", + " // Some OpenCode versions may emit session.busy/session.idle as separate events", + ' if (event.type === "session.busy") {', + " await handleBusy(sessionID);", + " }", ' if (event.type === "session.idle") {', " await handleStop(sessionID, 'session.idle');", " }", diff --git a/apps/desktop/src/main/lib/notifications/server.ts b/apps/desktop/src/main/lib/notifications/server.ts index 8a47e93af91..089824f0f92 100644 --- a/apps/desktop/src/main/lib/notifications/server.ts +++ b/apps/desktop/src/main/lib/notifications/server.ts @@ -3,9 +3,16 @@ import express from "express"; import { NOTIFICATION_EVENTS } from "shared/constants"; import { debugLog } from "shared/debug"; import { env } from "shared/env.shared"; +import type { AgentLifecycleEvent } from "shared/notification-types"; import { appState } from "../app-state"; import { HOOK_PROTOCOL_VERSION } from "../terminal/env"; +// Re-export types for backwards compatibility +export type { + AgentLifecycleEvent, + NotificationIds, +} from "shared/notification-types"; + /** * The environment this server is running in. * Used to validate incoming hook requests and detect cross-environment issues. @@ -13,16 +20,6 @@ import { HOOK_PROTOCOL_VERSION } from "../terminal/env"; const SERVER_ENV = env.NODE_ENV === "development" ? "development" : "production"; -export interface NotificationIds { - paneId?: string; - tabId?: string; - workspaceId?: string; -} - -export interface AgentLifecycleEvent extends NotificationIds { - eventType: "Start" | "Stop" | "PermissionRequest"; -} - export const notificationsEmitter = new EventEmitter(); const app = express(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 8f2afcbba8b..170afe5fd04 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -112,23 +112,23 @@ export function WorkspaceListItem({ }, ); - // Check if any pane in tabs belonging to this workspace needs attention (agent notifications) - const workspaceTabs = tabs.filter((t) => t.workspaceId === id); - const workspacePaneIds = new Set( - workspaceTabs.flatMap((t) => extractPaneIdsFromLayout(t.layout)), - ); + // Memoize workspace pane IDs to avoid recalculating on every render + const workspacePaneIds = useMemo(() => { + const workspaceTabs = tabs.filter((t) => t.workspaceId === id); + return new Set( + workspaceTabs.flatMap((t) => extractPaneIdsFromLayout(t.layout)), + ); + }, [tabs, id]); // Compute aggregate status for workspace (priority: permission > working > review) + // Uses direct pane lookup by ID for O(workspacePanes) instead of O(totalPanes) const workspaceStatus = useMemo((): Exclude | null => { - const workspacePanes = Object.values(panes).filter( - (p) => p != null && workspacePaneIds.has(p.id), - ); - let hasWorking = false; let hasReview = false; - for (const pane of workspacePanes) { - if (!pane.status || pane.status === "idle") continue; + for (const paneId of workspacePaneIds) { + const pane = panes[paneId]; + if (!pane?.status || pane.status === "idle") continue; if (pane.status === "permission") { return "permission"; // Highest priority, return immediately diff --git a/apps/desktop/src/renderer/stores/tabs/utils/resolve-notification-target.ts b/apps/desktop/src/renderer/stores/tabs/utils/resolve-notification-target.ts index 65bcf1ea01a..84b1646989f 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils/resolve-notification-target.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils/resolve-notification-target.ts @@ -1,4 +1,4 @@ -import type { NotificationIds } from "main/lib/notifications/server"; +import type { NotificationIds } from "shared/notification-types"; import type { Pane, Tab } from "../types"; interface TabsState { diff --git a/apps/desktop/src/shared/notification-types.ts b/apps/desktop/src/shared/notification-types.ts new file mode 100644 index 00000000000..8339cf3140a --- /dev/null +++ b/apps/desktop/src/shared/notification-types.ts @@ -0,0 +1,14 @@ +/** + * Shared notification types used by both main and renderer processes. + * Kept in shared/ to avoid cross-boundary imports. + */ + +export interface NotificationIds { + paneId?: string; + tabId?: string; + workspaceId?: string; +} + +export interface AgentLifecycleEvent extends NotificationIds { + eventType: "Start" | "Stop" | "PermissionRequest"; +} From 6a815d86a1cb8c5595d3d55218b465368c84e900 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 13:01:07 +0200 Subject: [PATCH 14/24] chore: revert .gitignore change --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index 8e1224ea24a..da7b0a9ec27 100644 --- a/.gitignore +++ b/.gitignore @@ -67,5 +67,3 @@ next-env.d.ts # Reference material downloaded for agents examples -# Internal artifacts -thoughts/ From 07189fc1fcd572ba8835cb271fb73c63ae3feb69 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 13:02:09 +0200 Subject: [PATCH 15/24] fix(desktop): reset rootSessionID after Stop for new sessions When OpenCode starts a new conversation/session within the same process, we need to be able to track it. Previously we stayed locked to the first session's ID forever. Now we reset rootSessionID after sending Stop so the next busy event can establish a new root session. --- .../main/lib/agent-setup/agent-wrappers.ts | 4 + .../CONTINUITY_CLAUDE-rebase-to-main.md | 75 +++++++++++++++++++ .../auto-handoff-2026-01-06T07-39-46.md | 29 +++++++ .../auto-handoff-2026-01-06T08-28-24.md | 28 +++++++ .../auto-handoff-2026-01-06T09-54-24.md | 28 +++++++ 5 files changed, 164 insertions(+) create mode 100644 thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md create mode 100644 thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T07-39-46.md create mode 100644 thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T08-28-24.md create mode 100644 thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T09-54-24.md diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index 3110b09847d..886cb9b6a35 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -261,6 +261,7 @@ export function getOpenCodePluginContent(notifyPath: string): string { " /**", " * Handles state transition to idle/stopped.", " * Only sends Stop once per busy period and only for root session.", + " * Resets rootSessionID after Stop so we can track new sessions.", " */", " const handleStop = async (sessionID, reason) => {", " // Only process events for our root session (if we have one)", @@ -275,6 +276,9 @@ export function getOpenCodePluginContent(notifyPath: string): string { " stopSent = true;", " log('Stopping, reason:', reason);", " await notify('Stop');", + " // Reset rootSessionID so we can track a new session if OpenCode starts another conversation", + " rootSessionID = null;", + " log('Reset rootSessionID for next session');", " } else {", " log('Skipping Stop - state:', currentState, 'stopSent:', stopSent, 'reason:', reason);", " }", diff --git a/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md b/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md new file mode 100644 index 00000000000..2aec3beefaa --- /dev/null +++ b/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md @@ -0,0 +1,75 @@ +--- +created: 2026-01-06T12:00:00Z +last_updated: 2026-01-06T16:15:00Z +session_count: 3 +status: IN_PROGRESS +--- + +# Session: Working Indicator Feature + +## Goal +Ship 3-color workspace status indicators on main branch, with proper OpenCode support. + +## Completed Work + +### 1. Rebase onto main (DONE) +- Cherry-picked `c3a67201` onto main (was on persistent-terminals) +- Resolved 8 conflicts, dropped persistent-terminals code +- Pushed as commit `a9cd65d4` + +### 2. Fix sidebar StatusIndicator regression (DONE) +- WorkspaceListItem.tsx had hardcoded red dot instead of 3-color StatusIndicator +- Added `workspaceStatus` computation with priority: permission > working > review +- Replaced hardcoded red with StatusIndicator component +- Pushed as commit `6e4a8d0e` + +### 3. Updated PR #588 (DONE) +- Changed base branch from persistent-terminals → main +- Updated description with UI locations, QA checklist, indicator colors + +### 4. Fix OpenCode completion detection (DONE) +**Root cause found:** OpenCode uses `session.busy` and `session.idle` as separate event types, NOT `session.status` with nested `status.type`. + +**Fix applied:** +- OLD: `session.status` with `status.type === "busy"` / `"idle"` +- NEW: `session.busy` / `session.idle` as separate events +- Bumped plugin version to v5 +- Committed as `649258f7` + +### 5. Fix stuck indicator on agent quit (DONE) +**Problem:** When agent is quit via Ctrl+C or exit, Stop hook never fires and indicator stays amber forever. + +**Root cause:** Terminal exit events were disconnected from pane status cleanup. + +**Solution:** Clear "working" and "permission" status when terminal exits. +- Added `setPaneStatus` selector to Terminal.tsx +- Added status clearing in both exit handlers (handleStreamData and flushPendingEvents) +- "review" status preserved (user should see completed work) +- Updated useAgentHookListener.ts documentation +- Committed as `60c36400` + +## Current Work + +### Ready for QA (IN PROGRESS) +All fixes applied, needs manual testing: +1. OpenCode completion detection +2. Stuck indicator on quit + +## Next Steps +- [ ] Manual test with Claude and OpenCode +- [ ] Get PR merged + +## Key Files +- `apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts` - OpenCode plugin (FIXED) +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx` - Terminal exit cleanup (FIXED) +- `apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts` - Agent lifecycle docs (UPDATED) + +## Working Set +- Branch: `working-indicator` +- PR: https://github.com/superset-sh/superset/pull/588 +- Latest commit: `60c36400 fix(desktop): clear pane status on terminal exit to fix stuck indicators` + +## Key Learnings +- OpenCode emits `session.busy` and `session.idle` as separate events (not nested in `session.status`) +- Terminal exit events (from node-pty) are reliable and always fire +- Connecting terminal exit to status cleanup provides a robust fallback for stuck indicators diff --git a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T07-39-46.md b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T07-39-46.md new file mode 100644 index 00000000000..6f4fa351fea --- /dev/null +++ b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T07-39-46.md @@ -0,0 +1,29 @@ +--- +type: auto-handoff +date: 2026-01-06T07:39:46.734Z +session_name: rebase-to-main +trigger: pre-compact +--- + +# Auto-Handoff: [→] Cherry-pick and resolve conflicts + +This handoff was automatically created before context compaction. + +## In Progress + +[→] Cherry-pick and resolve conflicts + +## Recent Completed + +[x] Identified all 8 conflicts for cherry-pick +- Done: [x] Analyzed conflict types (6 content, 2 deleted-by-us) + +## Resume Instructions + +1. Read this handoff to understand current state +2. Check the ledger for full context: thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md +3. Continue from where you left off + +## Source Ledger + +/Users/andreasasprou/.superset/worktrees/superset/workingindicator/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md diff --git a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T08-28-24.md b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T08-28-24.md new file mode 100644 index 00000000000..7ad729d91da --- /dev/null +++ b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T08-28-24.md @@ -0,0 +1,28 @@ +--- +type: auto-handoff +date: 2026-01-06T08:28:24.660Z +session_name: rebase-to-main +trigger: pre-compact +--- + +# Auto-Handoff: Unknown + +This handoff was automatically created before context compaction. + +## In Progress + +Unknown + +## Recent Completed + +None tracked + +## Resume Instructions + +1. Read this handoff to understand current state +2. Check the ledger for full context: thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md +3. Continue from where you left off + +## Source Ledger + +/Users/andreasasprou/.superset/worktrees/superset/workingindicator/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md diff --git a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T09-54-24.md b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T09-54-24.md new file mode 100644 index 00000000000..eca326e7a82 --- /dev/null +++ b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T09-54-24.md @@ -0,0 +1,28 @@ +--- +type: auto-handoff +date: 2026-01-06T09:54:24.216Z +session_name: rebase-to-main +trigger: pre-compact +--- + +# Auto-Handoff: Unknown + +This handoff was automatically created before context compaction. + +## In Progress + +Unknown + +## Recent Completed + +None tracked + +## Resume Instructions + +1. Read this handoff to understand current state +2. Check the ledger for full context: thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md +3. Continue from where you left off + +## Source Ledger + +/Users/andreasasprou/.superset/worktrees/superset/workingindicator/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md From 0467bc39d4d9eb8c37521db1abcb198a587e3bf4 Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 13:02:18 +0200 Subject: [PATCH 16/24] chore: remove thoughts/ from PR again --- .../CONTINUITY_CLAUDE-rebase-to-main.md | 75 ------------------- .../auto-handoff-2026-01-06T07-39-46.md | 29 ------- .../auto-handoff-2026-01-06T08-28-24.md | 28 ------- .../auto-handoff-2026-01-06T09-54-24.md | 28 ------- 4 files changed, 160 deletions(-) delete mode 100644 thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md delete mode 100644 thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T07-39-46.md delete mode 100644 thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T08-28-24.md delete mode 100644 thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T09-54-24.md diff --git a/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md b/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md deleted file mode 100644 index 2aec3beefaa..00000000000 --- a/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -created: 2026-01-06T12:00:00Z -last_updated: 2026-01-06T16:15:00Z -session_count: 3 -status: IN_PROGRESS ---- - -# Session: Working Indicator Feature - -## Goal -Ship 3-color workspace status indicators on main branch, with proper OpenCode support. - -## Completed Work - -### 1. Rebase onto main (DONE) -- Cherry-picked `c3a67201` onto main (was on persistent-terminals) -- Resolved 8 conflicts, dropped persistent-terminals code -- Pushed as commit `a9cd65d4` - -### 2. Fix sidebar StatusIndicator regression (DONE) -- WorkspaceListItem.tsx had hardcoded red dot instead of 3-color StatusIndicator -- Added `workspaceStatus` computation with priority: permission > working > review -- Replaced hardcoded red with StatusIndicator component -- Pushed as commit `6e4a8d0e` - -### 3. Updated PR #588 (DONE) -- Changed base branch from persistent-terminals → main -- Updated description with UI locations, QA checklist, indicator colors - -### 4. Fix OpenCode completion detection (DONE) -**Root cause found:** OpenCode uses `session.busy` and `session.idle` as separate event types, NOT `session.status` with nested `status.type`. - -**Fix applied:** -- OLD: `session.status` with `status.type === "busy"` / `"idle"` -- NEW: `session.busy` / `session.idle` as separate events -- Bumped plugin version to v5 -- Committed as `649258f7` - -### 5. Fix stuck indicator on agent quit (DONE) -**Problem:** When agent is quit via Ctrl+C or exit, Stop hook never fires and indicator stays amber forever. - -**Root cause:** Terminal exit events were disconnected from pane status cleanup. - -**Solution:** Clear "working" and "permission" status when terminal exits. -- Added `setPaneStatus` selector to Terminal.tsx -- Added status clearing in both exit handlers (handleStreamData and flushPendingEvents) -- "review" status preserved (user should see completed work) -- Updated useAgentHookListener.ts documentation -- Committed as `60c36400` - -## Current Work - -### Ready for QA (IN PROGRESS) -All fixes applied, needs manual testing: -1. OpenCode completion detection -2. Stuck indicator on quit - -## Next Steps -- [ ] Manual test with Claude and OpenCode -- [ ] Get PR merged - -## Key Files -- `apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts` - OpenCode plugin (FIXED) -- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx` - Terminal exit cleanup (FIXED) -- `apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts` - Agent lifecycle docs (UPDATED) - -## Working Set -- Branch: `working-indicator` -- PR: https://github.com/superset-sh/superset/pull/588 -- Latest commit: `60c36400 fix(desktop): clear pane status on terminal exit to fix stuck indicators` - -## Key Learnings -- OpenCode emits `session.busy` and `session.idle` as separate events (not nested in `session.status`) -- Terminal exit events (from node-pty) are reliable and always fire -- Connecting terminal exit to status cleanup provides a robust fallback for stuck indicators diff --git a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T07-39-46.md b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T07-39-46.md deleted file mode 100644 index 6f4fa351fea..00000000000 --- a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T07-39-46.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -type: auto-handoff -date: 2026-01-06T07:39:46.734Z -session_name: rebase-to-main -trigger: pre-compact ---- - -# Auto-Handoff: [→] Cherry-pick and resolve conflicts - -This handoff was automatically created before context compaction. - -## In Progress - -[→] Cherry-pick and resolve conflicts - -## Recent Completed - -[x] Identified all 8 conflicts for cherry-pick -- Done: [x] Analyzed conflict types (6 content, 2 deleted-by-us) - -## Resume Instructions - -1. Read this handoff to understand current state -2. Check the ledger for full context: thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md -3. Continue from where you left off - -## Source Ledger - -/Users/andreasasprou/.superset/worktrees/superset/workingindicator/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md diff --git a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T08-28-24.md b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T08-28-24.md deleted file mode 100644 index 7ad729d91da..00000000000 --- a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T08-28-24.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -type: auto-handoff -date: 2026-01-06T08:28:24.660Z -session_name: rebase-to-main -trigger: pre-compact ---- - -# Auto-Handoff: Unknown - -This handoff was automatically created before context compaction. - -## In Progress - -Unknown - -## Recent Completed - -None tracked - -## Resume Instructions - -1. Read this handoff to understand current state -2. Check the ledger for full context: thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md -3. Continue from where you left off - -## Source Ledger - -/Users/andreasasprou/.superset/worktrees/superset/workingindicator/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md diff --git a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T09-54-24.md b/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T09-54-24.md deleted file mode 100644 index eca326e7a82..00000000000 --- a/thoughts/shared/handoffs/rebase-to-main/auto-handoff-2026-01-06T09-54-24.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -type: auto-handoff -date: 2026-01-06T09:54:24.216Z -session_name: rebase-to-main -trigger: pre-compact ---- - -# Auto-Handoff: Unknown - -This handoff was automatically created before context compaction. - -## In Progress - -Unknown - -## Recent Completed - -None tracked - -## Resume Instructions - -1. Read this handoff to understand current state -2. Check the ledger for full context: thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md -3. Continue from where you left off - -## Source Ledger - -/Users/andreasasprou/.superset/worktrees/superset/workingindicator/thoughts/ledgers/CONTINUITY_CLAUDE-rebase-to-main.md From 250ab5c60a397fbf4e79eb2dcb450d49cb0c328f Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 13:29:56 +0200 Subject: [PATCH 17/24] refactor(desktop): centralize status priority logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract duplicated priority order (permission > working > review) into shared utilities: - Add STATUS_PRIORITY constant as single source of truth - Add pickHigherStatus() for map aggregation patterns - Add getHighestPriorityStatus() for iterable scanning - Use generators to avoid array allocations Simplifies WorkspaceListItem (22 → 9 lines) and GroupStrip (18 → 9 lines) while making priority order easily testable and future-proof. --- .../StatusIndicator/StatusIndicator.tsx | 9 ++-- .../WorkspaceListItem/WorkspaceListItem.tsx | 30 +++--------- .../TabsContent/GroupStrip/GroupStrip.tsx | 25 ++++------ apps/desktop/src/shared/tabs-types.ts | 49 +++++++++++++++++++ 4 files changed, 71 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/StatusIndicator/StatusIndicator.tsx b/apps/desktop/src/renderer/screens/main/components/StatusIndicator/StatusIndicator.tsx index e446e30469e..ea42c6c3412 100644 --- a/apps/desktop/src/renderer/screens/main/components/StatusIndicator/StatusIndicator.tsx +++ b/apps/desktop/src/renderer/screens/main/components/StatusIndicator/StatusIndicator.tsx @@ -1,5 +1,8 @@ import { cn } from "@superset/ui/utils"; -import type { PaneStatus } from "shared/tabs-types"; +import type { ActivePaneStatus } from "shared/tabs-types"; + +// Re-export for consumers +export type { ActivePaneStatus } from "shared/tabs-types"; /** Lookup object for status indicator styling - avoids if/else chains */ const STATUS_CONFIG = { @@ -22,12 +25,10 @@ const STATUS_CONFIG = { tooltip: "Ready for review", }, } as const satisfies Record< - Exclude, + ActivePaneStatus, { pingColor: string; dotColor: string; pulse: boolean; tooltip: string } >; -export type ActivePaneStatus = keyof typeof STATUS_CONFIG; - interface StatusIndicatorProps { status: ActivePaneStatus; className?: string; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 170afe5fd04..65ed0784075 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -30,7 +30,7 @@ import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRena import { useCloseWorkspacesList } from "renderer/stores/app-state"; import { useTabsStore } from "renderer/stores/tabs/store"; import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; -import type { PaneStatus } from "shared/tabs-types"; +import { getHighestPriorityStatus } from "shared/tabs-types"; import { STROKE_WIDTH } from "../constants"; import { BranchSwitcher, @@ -120,29 +120,15 @@ export function WorkspaceListItem({ ); }, [tabs, id]); - // Compute aggregate status for workspace (priority: permission > working > review) - // Uses direct pane lookup by ID for O(workspacePanes) instead of O(totalPanes) - const workspaceStatus = useMemo((): Exclude | null => { - let hasWorking = false; - let hasReview = false; - - for (const paneId of workspacePaneIds) { - const pane = panes[paneId]; - if (!pane?.status || pane.status === "idle") continue; - - if (pane.status === "permission") { - return "permission"; // Highest priority, return immediately - } - if (pane.status === "working") { - hasWorking = true; - } else if (pane.status === "review") { - hasReview = true; + // Compute aggregate status for workspace using shared priority logic + const workspaceStatus = useMemo(() => { + // Generator avoids array allocation + function* paneStatuses() { + for (const paneId of workspacePaneIds) { + yield panes[paneId]?.status; } } - - if (hasWorking) return "working"; - if (hasReview) return "review"; - return null; + return getHighestPriorityStatus(paneStatuses()); }, [panes, workspacePaneIds]); const handleClick = () => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index 6728fafce4f..1d586d5f403 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -24,7 +24,10 @@ import { trpc } from "renderer/lib/trpc"; import { usePresets } from "renderer/react-query/presets"; import { useOpenSettings } from "renderer/stores"; import { useTabsStore } from "renderer/stores/tabs/store"; -import type { PaneStatus } from "renderer/stores/tabs/types"; +import { + pickHigherStatus, + type ActivePaneStatus, +} from "shared/tabs-types"; import { GroupItem } from "./GroupItem"; export function GroupStrip() { @@ -75,24 +78,14 @@ export function GroupStrip() { ? activeTabIds[activeWorkspaceId] : null; - // Compute aggregate status per tab (priority: permission > working > review) + // Compute aggregate status per tab using shared priority logic const tabStatusMap = useMemo(() => { - const result = new Map(); + const result = new Map(); for (const pane of Object.values(panes)) { if (!pane.status || pane.status === "idle") continue; - - const currentStatus = result.get(pane.tabId); - // Priority: permission > working > review - if (pane.status === "permission") { - result.set(pane.tabId, "permission"); - } else if (pane.status === "working" && currentStatus !== "permission") { - result.set(pane.tabId, "working"); - } else if ( - pane.status === "review" && - currentStatus !== "permission" && - currentStatus !== "working" - ) { - result.set(pane.tabId, "review"); + const higher = pickHigherStatus(result.get(pane.tabId), pane.status); + if (higher !== "idle") { + result.set(pane.tabId, higher); } } return result; diff --git a/apps/desktop/src/shared/tabs-types.ts b/apps/desktop/src/shared/tabs-types.ts index 698f7f08dae..101dc9d885b 100644 --- a/apps/desktop/src/shared/tabs-types.ts +++ b/apps/desktop/src/shared/tabs-types.ts @@ -19,6 +19,55 @@ export type PaneType = "terminal" | "webview" | "file-viewer"; */ export type PaneStatus = "idle" | "working" | "permission" | "review"; +/** Non-idle status for UI indicators */ +export type ActivePaneStatus = Exclude; + +/** + * Status priority order (higher = more urgent). + * Single source of truth for aggregation logic. + */ +export const STATUS_PRIORITY = { + idle: 0, + review: 1, + working: 2, + permission: 3, +} as const satisfies Record; + +/** + * Compare two statuses and return the higher priority one. + * Useful for reducing/folding over pane statuses. + */ +export function pickHigherStatus( + a: PaneStatus | undefined, + b: PaneStatus | undefined, +): PaneStatus { + const aPriority = a ? STATUS_PRIORITY[a] : 0; + const bPriority = b ? STATUS_PRIORITY[b] : 0; + if (aPriority >= bPriority) return a ?? "idle"; + return b ?? "idle"; +} + +/** + * Get the highest priority status from an iterable of statuses. + * Returns null if all statuses are idle/undefined (no indicator needed). + */ +export function getHighestPriorityStatus( + statuses: Iterable, +): ActivePaneStatus | null { + let highest: PaneStatus = "idle"; + + for (const status of statuses) { + if (!status) continue; + if (STATUS_PRIORITY[status] > STATUS_PRIORITY[highest]) { + highest = status; + // Early exit for max priority + if (highest === "permission") break; + } + } + + return highest === "idle" ? null : highest; +} + /** * File viewer display modes */ From 7c7a7b792ce39dad648e69348adf09b31f4f866d Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Tue, 6 Jan 2026 13:32:41 +0200 Subject: [PATCH 18/24] chore: fix import organization --- .../ContentView/TabsContent/GroupStrip/GroupStrip.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index 1d586d5f403..a984cc9dfc6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -24,10 +24,7 @@ import { trpc } from "renderer/lib/trpc"; import { usePresets } from "renderer/react-query/presets"; import { useOpenSettings } from "renderer/stores"; import { useTabsStore } from "renderer/stores/tabs/store"; -import { - pickHigherStatus, - type ActivePaneStatus, -} from "shared/tabs-types"; +import { type ActivePaneStatus, pickHigherStatus } from "shared/tabs-types"; import { GroupItem } from "./GroupItem"; export function GroupStrip() { From c3e32f93a5a95acce5a2533473b9dce35a12a0b2 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 6 Jan 2026 11:41:28 -0800 Subject: [PATCH 19/24] Use external file instead of inline content --- .../main/lib/agent-setup/agent-wrappers.ts | 204 +----------------- .../templates/opencode-plugin.template.js | 187 ++++++++++++++++ biome.jsonc | 2 +- 3 files changed, 199 insertions(+), 194 deletions(-) create mode 100644 apps/desktop/src/main/lib/agent-setup/templates/opencode-plugin.template.js diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index 886cb9b6a35..ee621af9ba3 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -135,200 +135,18 @@ exec "$REAL_BIN" "$@" `; } +/** + * Generates the OpenCode plugin content by reading from a template file + * and replacing the notify path placeholder. + */ export function getOpenCodePluginContent(notifyPath: string): string { - // Build "${" via char codes to avoid JS template literal interpolation in generated code - const templateOpen = String.fromCharCode(36, 123); - const shellLine = ` await $\`bash ${templateOpen}notifyPath} ${templateOpen}payload}\`;`; - return [ - OPENCODE_PLUGIN_MARKER, - "/**", - " * Superset Notification Plugin for OpenCode", - " *", - " * This plugin sends desktop notifications when OpenCode sessions need attention.", - " * It hooks into session.status (busy/idle), session.idle, session.error, and permission.ask events.", - " *", - " * ROBUSTNESS FEATURES (v8):", - " * - Session-scoped: Tracks root sessionID, ignores events from other sessions", - " * - Deduplication: Only sends Start on idle→busy, Stop on busy→idle transitions", - " * - Safe defaults: On error, assumes child session to avoid false positives", - " * - Debug logging: Set SUPERSET_DEBUG=1 to enable verbose logging", - " *", - " * SUBAGENT FILTERING:", - " * When using oh-my-opencode or similar tools that spawn background subagents", - " * (e.g., explore, librarian, oracle agents), each subagent runs in its own", - " * OpenCode session. These child sessions emit session.idle events when they", - " * complete, which would cause excessive notifications if not filtered.", - " *", - " * We detect child sessions by checking the `parentID` field - main/root sessions", - " * have `parentID` as undefined, while child sessions have it set.", - " *", - " * @see https://github.com/sst/opencode/blob/dev/packages/app/src/context/notification.tsx", - " */", - "export const SupersetNotifyPlugin = async ({ $, client }) => {", - " if (globalThis.__supersetOpencodeNotifyPluginV8) return {};", - " globalThis.__supersetOpencodeNotifyPluginV8 = true;", - "", - " // Only run inside a Superset terminal session", - " if (!process?.env?.SUPERSET_TAB_ID) return {};", - "", - ` const notifyPath = "${notifyPath}";`, - " const debug = process?.env?.SUPERSET_DEBUG === '1';", - "", - " // State tracking for deduplication and session-scoping", - " let currentState = 'idle'; // 'idle' | 'busy'", - " let rootSessionID = null; // The session we're tracking (first busy session)", - " let stopSent = false; // Prevent duplicate Stop notifications", - "", - " const log = (...args) => {", - " if (debug) console.log('[superset-plugin]', ...args);", - " };", - "", - " /**", - " * Sends a notification to Superset's notification server.", - " * Best-effort only - failures are silently ignored to avoid breaking the agent.", - " */", - " const notify = async (hookEventName) => {", - " const payload = JSON.stringify({ hook_event_name: hookEventName });", - " log('Sending notification:', hookEventName);", - " try {", - shellLine, - " log('Notification sent successfully');", - " } catch (err) {", - " log('Notification failed:', err?.message || err);", - " }", - " };", - "", - " /**", - " * Checks if a session is a child/subagent session by looking up its parentID.", - " * Uses caching to avoid repeated lookups for the same session.", - " *", - " * IMPORTANT: On error, returns TRUE (assumes child) to avoid false positives.", - " * This prevents race conditions where a failed lookup causes child session", - " * events to be treated as root session events.", - " */", - " const childSessionCache = new Map();", - " const isChildSession = async (sessionID) => {", - " if (!sessionID) return true; // No sessionID = can't verify, skip", - " if (!client?.session?.list) return true; // Can't check, skip", - "", - " // Check cache first", - " if (childSessionCache.has(sessionID)) {", - " return childSessionCache.get(sessionID);", - " }", - "", - " try {", - " const sessions = await client.session.list();", - " const session = sessions.data?.find((s) => s.id === sessionID);", - " const isChild = !!session?.parentID;", - " childSessionCache.set(sessionID, isChild);", - " log('Session lookup:', sessionID, 'isChild:', isChild);", - " return isChild;", - " } catch (err) {", - " log('Session lookup failed:', err?.message || err, '- assuming child');", - " // On error, assume child session to avoid false positives", - " // This prevents race conditions where failures cause incorrect notifications", - " return true;", - " }", - " };", - "", - " /**", - " * Handles state transition to busy.", - " * Only sends Start if transitioning from idle and session matches root.", - " */", - " const handleBusy = async (sessionID) => {", - " // If we don't have a root session yet, this becomes our root", - " if (!rootSessionID) {", - " rootSessionID = sessionID;", - " log('Root session set:', rootSessionID);", - " }", - "", - " // Only process events for our root session", - " if (sessionID !== rootSessionID) {", - " log('Ignoring busy from non-root session:', sessionID);", - " return;", - " }", - "", - " // Only send Start if transitioning from idle", - " if (currentState === 'idle') {", - " currentState = 'busy';", - " stopSent = false; // Reset stop flag for new busy period", - " await notify('Start');", - " } else {", - " log('Already busy, skipping Start');", - " }", - " };", - "", - " /**", - " * Handles state transition to idle/stopped.", - " * Only sends Stop once per busy period and only for root session.", - " * Resets rootSessionID after Stop so we can track new sessions.", - " */", - " const handleStop = async (sessionID, reason) => {", - " // Only process events for our root session (if we have one)", - " if (rootSessionID && sessionID !== rootSessionID) {", - " log('Ignoring stop from non-root session:', sessionID, 'reason:', reason);", - " return;", - " }", - "", - " // Only send Stop if we're busy and haven't already sent Stop", - " if (currentState === 'busy' && !stopSent) {", - " currentState = 'idle';", - " stopSent = true;", - " log('Stopping, reason:', reason);", - " await notify('Stop');", - " // Reset rootSessionID so we can track a new session if OpenCode starts another conversation", - " rootSessionID = null;", - " log('Reset rootSessionID for next session');", - " } else {", - " log('Skipping Stop - state:', currentState, 'stopSent:', stopSent, 'reason:', reason);", - " }", - " };", - "", - " return {", - " event: async ({ event }) => {", - " const sessionID = event.properties?.sessionID;", - " log('Event:', event.type, 'sessionID:', sessionID);", - "", - " // Skip notifications for child/subagent sessions", - " if (await isChildSession(sessionID)) {", - " log('Skipping child session');", - " return;", - " }", - "", - " // Handle session status changes (busy/idle/retry)", - ' if (event.type === "session.status") {', - " const status = event.properties?.status;", - " log('Status:', status?.type);", - ' if (status?.type === "busy") {', - " await handleBusy(sessionID);", - ' } else if (status?.type === "idle") {', - " await handleStop(sessionID, 'session.status.idle');", - " }", - " }", - "", - " // Handle deprecated/alternative event types (backwards compatibility)", - " // Some OpenCode versions may emit session.busy/session.idle as separate events", - ' if (event.type === "session.busy") {', - " await handleBusy(sessionID);", - " }", - ' if (event.type === "session.idle") {', - " await handleStop(sessionID, 'session.idle');", - " }", - "", - " // Handle session errors (also means session stopped)", - ' if (event.type === "session.error") {', - " await handleStop(sessionID, 'session.error');", - " }", - " },", - ' "permission.ask": async (_permission, output) => {', - ' if (output.status === "ask") {', - ' await notify("PermissionRequest");', - " }", - " },", - " };", - "};", - "", - ].join("\n"); + const templatePath = path.join( + __dirname, + "templates", + "opencode-plugin.template.js", + ); + const template = fs.readFileSync(templatePath, "utf-8"); + return template.replace("{{NOTIFY_PATH}}", notifyPath); } /** diff --git a/apps/desktop/src/main/lib/agent-setup/templates/opencode-plugin.template.js b/apps/desktop/src/main/lib/agent-setup/templates/opencode-plugin.template.js new file mode 100644 index 00000000000..e04c79ff4d7 --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/opencode-plugin.template.js @@ -0,0 +1,187 @@ +// Superset opencode plugin v8 +/** + * Superset Notification Plugin for OpenCode + * + * This plugin sends desktop notifications when OpenCode sessions need attention. + * It hooks into session.status (busy/idle), session.idle, session.error, and permission.ask events. + * + * ROBUSTNESS FEATURES (v8): + * - Session-scoped: Tracks root sessionID, ignores events from other sessions + * - Deduplication: Only sends Start on idle→busy, Stop on busy→idle transitions + * - Safe defaults: On error, assumes child session to avoid false positives + * - Debug logging: Set SUPERSET_DEBUG=1 to enable verbose logging + * + * SUBAGENT FILTERING: + * When using oh-my-opencode or similar tools that spawn background subagents + * (e.g., explore, librarian, oracle agents), each subagent runs in its own + * OpenCode session. These child sessions emit session.idle events when they + * complete, which would cause excessive notifications if not filtered. + * + * We detect child sessions by checking the `parentID` field - main/root sessions + * have `parentID` as undefined, while child sessions have it set. + * + * @see https://github.com/sst/opencode/blob/dev/packages/app/src/context/notification.tsx + */ +export const SupersetNotifyPlugin = async ({ $, client }) => { + if (globalThis.__supersetOpencodeNotifyPluginV8) return {}; + globalThis.__supersetOpencodeNotifyPluginV8 = true; + + // Only run inside a Superset terminal session + if (!process?.env?.SUPERSET_TAB_ID) return {}; + + const notifyPath = "{{NOTIFY_PATH}}"; + const debug = process?.env?.SUPERSET_DEBUG === '1'; + + // State tracking for deduplication and session-scoping + let currentState = 'idle'; // 'idle' | 'busy' + let rootSessionID = null; // The session we're tracking (first busy session) + let stopSent = false; // Prevent duplicate Stop notifications + + const log = (...args) => { + if (debug) console.log('[superset-plugin]', ...args); + }; + + /** + * Sends a notification to Superset's notification server. + * Best-effort only - failures are silently ignored to avoid breaking the agent. + */ + const notify = async (hookEventName) => { + const payload = JSON.stringify({ hook_event_name: hookEventName }); + log('Sending notification:', hookEventName); + try { + await $`bash ${notifyPath} ${payload}`; + log('Notification sent successfully'); + } catch (err) { + log('Notification failed:', err?.message || err); + } + }; + + /** + * Checks if a session is a child/subagent session by looking up its parentID. + * Uses caching to avoid repeated lookups for the same session. + * + * IMPORTANT: On error, returns TRUE (assumes child) to avoid false positives. + * This prevents race conditions where a failed lookup causes child session + * events to be treated as root session events. + */ + const childSessionCache = new Map(); + const isChildSession = async (sessionID) => { + if (!sessionID) return true; // No sessionID = can't verify, skip + if (!client?.session?.list) return true; // Can't check, skip + + // Check cache first + if (childSessionCache.has(sessionID)) { + return childSessionCache.get(sessionID); + } + + try { + const sessions = await client.session.list(); + const session = sessions.data?.find((s) => s.id === sessionID); + const isChild = !!session?.parentID; + childSessionCache.set(sessionID, isChild); + log('Session lookup:', sessionID, 'isChild:', isChild); + return isChild; + } catch (err) { + log('Session lookup failed:', err?.message || err, '- assuming child'); + // On error, assume child session to avoid false positives + // This prevents race conditions where failures cause incorrect notifications + return true; + } + }; + + /** + * Handles state transition to busy. + * Only sends Start if transitioning from idle and session matches root. + */ + const handleBusy = async (sessionID) => { + // If we don't have a root session yet, this becomes our root + if (!rootSessionID) { + rootSessionID = sessionID; + log('Root session set:', rootSessionID); + } + + // Only process events for our root session + if (sessionID !== rootSessionID) { + log('Ignoring busy from non-root session:', sessionID); + return; + } + + // Only send Start if transitioning from idle + if (currentState === 'idle') { + currentState = 'busy'; + stopSent = false; // Reset stop flag for new busy period + await notify('Start'); + } else { + log('Already busy, skipping Start'); + } + }; + + /** + * Handles state transition to idle/stopped. + * Only sends Stop once per busy period and only for root session. + * Resets rootSessionID after Stop so we can track new sessions. + */ + const handleStop = async (sessionID, reason) => { + // Only process events for our root session (if we have one) + if (rootSessionID && sessionID !== rootSessionID) { + log('Ignoring stop from non-root session:', sessionID, 'reason:', reason); + return; + } + + // Only send Stop if we're busy and haven't already sent Stop + if (currentState === 'busy' && !stopSent) { + currentState = 'idle'; + stopSent = true; + log('Stopping, reason:', reason); + await notify('Stop'); + // Reset rootSessionID so we can track a new session if OpenCode starts another conversation + rootSessionID = null; + log('Reset rootSessionID for next session'); + } else { + log('Skipping Stop - state:', currentState, 'stopSent:', stopSent, 'reason:', reason); + } + }; + + return { + event: async ({ event }) => { + const sessionID = event.properties?.sessionID; + log('Event:', event.type, 'sessionID:', sessionID); + + // Skip notifications for child/subagent sessions + if (await isChildSession(sessionID)) { + log('Skipping child session'); + return; + } + + // Handle session status changes (busy/idle/retry) + if (event.type === "session.status") { + const status = event.properties?.status; + log('Status:', status?.type); + if (status?.type === "busy") { + await handleBusy(sessionID); + } else if (status?.type === "idle") { + await handleStop(sessionID, 'session.status.idle'); + } + } + + // Handle deprecated/alternative event types (backwards compatibility) + // Some OpenCode versions may emit session.busy/session.idle as separate events + if (event.type === "session.busy") { + await handleBusy(sessionID); + } + if (event.type === "session.idle") { + await handleStop(sessionID, 'session.idle'); + } + + // Handle session errors (also means session stopped) + if (event.type === "session.error") { + await handleStop(sessionID, 'session.error'); + } + }, + "permission.ask": async (_permission, output) => { + if (output.status === "ask") { + await notify("PermissionRequest"); + } + }, + }; +}; diff --git a/biome.jsonc b/biome.jsonc index ce8290bd67a..ff7e9f3a995 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -6,7 +6,7 @@ "useIgnoreFile": true }, "files": { - "includes": ["**", "!**/drizzle"] + "includes": ["**", "!**/drizzle", "!**/*.template.js"] }, "formatter": { "formatWithErrors": true From 20a05ed5a005d4bc9e27f6b10f6dcf6ee2a58811 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 6 Jan 2026 11:45:42 -0800 Subject: [PATCH 20/24] Inject plugin marker --- .../main/lib/agent-setup/agent-wrappers.ts | 19 +++++++++++-------- .../templates/opencode-plugin.template.js | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index ee621af9ba3..2416cf961c5 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -14,6 +14,12 @@ export const CLAUDE_SETTINGS_FILE = "claude-settings.json"; export const OPENCODE_PLUGIN_FILE = "superset-notify.js"; export const OPENCODE_PLUGIN_MARKER = "// Superset opencode plugin v8"; +const OPENCODE_PLUGIN_TEMPLATE_PATH = path.join( + __dirname, + "templates", + "opencode-plugin.template.js", +); + const REAL_BINARY_RESOLVER = `find_real_binary() { local name="$1" local IFS=: @@ -137,16 +143,13 @@ exec "$REAL_BIN" "$@" /** * Generates the OpenCode plugin content by reading from a template file - * and replacing the notify path placeholder. + * and replacing placeholders. */ export function getOpenCodePluginContent(notifyPath: string): string { - const templatePath = path.join( - __dirname, - "templates", - "opencode-plugin.template.js", - ); - const template = fs.readFileSync(templatePath, "utf-8"); - return template.replace("{{NOTIFY_PATH}}", notifyPath); + const template = fs.readFileSync(OPENCODE_PLUGIN_TEMPLATE_PATH, "utf-8"); + return template + .replace("{{MARKER}}", OPENCODE_PLUGIN_MARKER) + .replace("{{NOTIFY_PATH}}", notifyPath); } /** diff --git a/apps/desktop/src/main/lib/agent-setup/templates/opencode-plugin.template.js b/apps/desktop/src/main/lib/agent-setup/templates/opencode-plugin.template.js index e04c79ff4d7..42b5a42f623 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/opencode-plugin.template.js +++ b/apps/desktop/src/main/lib/agent-setup/templates/opencode-plugin.template.js @@ -1,4 +1,4 @@ -// Superset opencode plugin v8 +{{MARKER}} /** * Superset Notification Plugin for OpenCode * From 9a78b8909de38964b8336b4253200bddb948cb83 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 6 Jan 2026 11:51:11 -0800 Subject: [PATCH 21/24] refactor and clean up comments --- .../main/lib/agent-setup/agent-wrappers.ts | 39 +++++-------------- 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index 2416cf961c5..345d66a4f89 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -12,7 +12,10 @@ import { export const WRAPPER_MARKER = "# Superset agent-wrapper v1"; export const CLAUDE_SETTINGS_FILE = "claude-settings.json"; export const OPENCODE_PLUGIN_FILE = "superset-notify.js"; -export const OPENCODE_PLUGIN_MARKER = "// Superset opencode plugin v8"; + +const OPENCODE_PLUGIN_SIGNATURE = "// Superset opencode plugin"; +const OPENCODE_PLUGIN_VERSION = "v8"; +export const OPENCODE_PLUGIN_MARKER = `${OPENCODE_PLUGIN_SIGNATURE} ${OPENCODE_PLUGIN_VERSION}`; const OPENCODE_PLUGIN_TEMPLATE_PATH = path.join( __dirname, @@ -62,11 +65,7 @@ export function getOpenCodePluginPath(): string { return path.join(OPENCODE_PLUGIN_DIR, OPENCODE_PLUGIN_FILE); } -/** - * OpenCode auto-loads plugins from ~/.config/opencode/plugin/ - * See: https://opencode.ai/docs/plugins - * The plugin checks SUPERSET_TAB_ID env var so it only activates in Superset terminals. - */ +/** @see https://opencode.ai/docs/plugins */ export function getOpenCodeGlobalPluginPath(): string { const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim(); const configHome = xdgConfigHome?.length @@ -141,10 +140,6 @@ exec "$REAL_BIN" "$@" `; } -/** - * Generates the OpenCode plugin content by reading from a template file - * and replacing placeholders. - */ export function getOpenCodePluginContent(notifyPath: string): string { const template = fs.readFileSync(OPENCODE_PLUGIN_TEMPLATE_PATH, "utf-8"); return template @@ -152,9 +147,6 @@ export function getOpenCodePluginContent(notifyPath: string): string { .replace("{{NOTIFY_PATH}}", notifyPath); } -/** - * Creates the Claude Code settings JSON file with notification hooks - */ function createClaudeSettings(): string { const settingsPath = getClaudeSettingsPath(); const notifyPath = getNotifyScriptPath(); @@ -164,9 +156,6 @@ function createClaudeSettings(): string { return settingsPath; } -/** - * Creates wrapper script for Claude Code - */ export function createClaudeWrapper(): void { const wrapperPath = getClaudeWrapperPath(); const settingsPath = createClaudeSettings(); @@ -175,9 +164,6 @@ export function createClaudeWrapper(): void { console.log("[agent-setup] Created Claude wrapper"); } -/** - * Creates wrapper script for Codex - */ export function createCodexWrapper(): void { const wrapperPath = getCodexWrapperPath(); const notifyPath = getNotifyScriptPath(); @@ -187,8 +173,7 @@ export function createCodexWrapper(): void { } /** - * Creates OpenCode plugin file with notification hooks. - * Only writes to environment-specific path - NOT the global path. + * Writes to environment-specific path only, NOT the global path. * Global path causes dev/prod conflicts when both are running. */ export function createOpenCodePlugin(): void { @@ -200,9 +185,8 @@ export function createOpenCodePlugin(): void { } /** - * Cleans up stale global OpenCode plugin that may have been written by older versions. - * Only removes if the file contains our marker to avoid deleting user-installed plugins. - * This prevents dev/prod cross-talk when both environments are running. + * Removes stale global plugin written by older versions. + * Only removes if the file contains our signature to avoid deleting user plugins. */ export function cleanupGlobalOpenCodePlugin(): void { try { @@ -210,15 +194,13 @@ export function cleanupGlobalOpenCodePlugin(): void { if (!fs.existsSync(globalPluginPath)) return; const content = fs.readFileSync(globalPluginPath, "utf-8"); - // Check for any version of our marker (v1, v2, v3, v4, etc.) - if (content.includes("// Superset opencode plugin")) { + if (content.includes(OPENCODE_PLUGIN_SIGNATURE)) { fs.unlinkSync(globalPluginPath); console.log( "[agent-setup] Removed stale global OpenCode plugin to prevent dev/prod conflicts", ); } } catch (error) { - // Ignore errors - this is best-effort cleanup console.warn( "[agent-setup] Failed to cleanup global OpenCode plugin:", error, @@ -226,9 +208,6 @@ export function cleanupGlobalOpenCodePlugin(): void { } } -/** - * Creates wrapper script for OpenCode - */ export function createOpenCodeWrapper(): void { const wrapperPath = getOpenCodeWrapperPath(); const script = buildOpenCodeWrapperScript(OPENCODE_CONFIG_DIR); From 8c2a9a3c1fdd6832c2df024edaf98b99ac384f68 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 6 Jan 2026 12:08:27 -0800 Subject: [PATCH 22/24] Move notify into its own file --- .../src/main/lib/agent-setup/notify-hook.ts | 60 ++++--------------- .../templates/notify-hook.template.sh | 46 ++++++++++++++ biome.jsonc | 2 +- 3 files changed, 57 insertions(+), 51 deletions(-) create mode 100644 apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts index 6940eca38f4..5026f3a6ac2 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts @@ -6,63 +6,23 @@ import { HOOKS_DIR } from "./paths"; export const NOTIFY_SCRIPT_NAME = "notify.sh"; export const NOTIFY_SCRIPT_MARKER = "# Superset agent notification hook"; +const NOTIFY_SCRIPT_TEMPLATE_PATH = path.join( + __dirname, + "templates", + "notify-hook.template.sh", +); + export function getNotifyScriptPath(): string { return path.join(HOOKS_DIR, NOTIFY_SCRIPT_NAME); } export function getNotifyScriptContent(): string { - return `#!/bin/bash -${NOTIFY_SCRIPT_MARKER} -# Called by CLI agents (Claude Code, Codex, etc.) when they complete or need input - -# Only run if inside a Superset terminal -[ -z "$SUPERSET_TAB_ID" ] && exit 0 - -# Get JSON input - Codex passes as argument, Claude pipes to stdin -if [ -n "$1" ]; then - INPUT="$1" -else - INPUT=$(cat) -fi - -# Extract event type - Claude uses "hook_event_name", Codex uses "type" -# Use flexible pattern to handle optional whitespace: "key": "value" or "key":"value" -EVENT_TYPE=$(echo "$INPUT" | grep -oE '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') -if [ -z "$EVENT_TYPE" ]; then - # Check for Codex "type" field (e.g., "agent-turn-complete") - CODEX_TYPE=$(echo "$INPUT" | grep -oE '"type"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') - if [ "$CODEX_TYPE" = "agent-turn-complete" ]; then - EVENT_TYPE="Stop" - fi -fi - -# NOTE: We intentionally do NOT default to "Stop" if EVENT_TYPE is empty. -# Parse failures should not trigger completion notifications. -# The server will ignore requests with missing eventType (forward compatibility). - -# Map UserPromptSubmit to Start for simpler handling -[ "$EVENT_TYPE" = "UserPromptSubmit" ] && EVENT_TYPE="Start" - -# If no event type was found, skip the notification -# This prevents parse failures from causing false completion notifications -[ -z "$EVENT_TYPE" ] && exit 0 - -# Timeouts prevent blocking agent completion if notification server is unresponsive -curl -sG "http://127.0.0.1:\${SUPERSET_PORT:-${PORTS.NOTIFICATIONS}}/hook/complete" \\ - --connect-timeout 1 --max-time 2 \\ - --data-urlencode "paneId=$SUPERSET_PANE_ID" \\ - --data-urlencode "tabId=$SUPERSET_TAB_ID" \\ - --data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \\ - --data-urlencode "eventType=$EVENT_TYPE" \\ - --data-urlencode "env=$SUPERSET_ENV" \\ - --data-urlencode "version=$SUPERSET_HOOK_VERSION" \\ - > /dev/null 2>&1 -`; + const template = fs.readFileSync(NOTIFY_SCRIPT_TEMPLATE_PATH, "utf-8"); + return template + .replace("{{MARKER}}", NOTIFY_SCRIPT_MARKER) + .replace("{{DEFAULT_PORT}}", String(PORTS.NOTIFICATIONS)); } -/** - * Creates the notify.sh script - */ export function createNotifyScript(): void { const notifyPath = getNotifyScriptPath(); const script = getNotifyScriptContent(); diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh new file mode 100644 index 00000000000..fc5dd405184 --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh @@ -0,0 +1,46 @@ +#!/bin/bash +{{MARKER}} +# Called by CLI agents (Claude Code, Codex, etc.) when they complete or need input + +# Only run if inside a Superset terminal +[ -z "$SUPERSET_TAB_ID" ] && exit 0 + +# Get JSON input - Codex passes as argument, Claude pipes to stdin +if [ -n "$1" ]; then + INPUT="$1" +else + INPUT=$(cat) +fi + +# Extract event type - Claude uses "hook_event_name", Codex uses "type" +# Use flexible pattern to handle optional whitespace: "key": "value" or "key":"value" +EVENT_TYPE=$(echo "$INPUT" | grep -oE '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') +if [ -z "$EVENT_TYPE" ]; then + # Check for Codex "type" field (e.g., "agent-turn-complete") + CODEX_TYPE=$(echo "$INPUT" | grep -oE '"type"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') + if [ "$CODEX_TYPE" = "agent-turn-complete" ]; then + EVENT_TYPE="Stop" + fi +fi + +# NOTE: We intentionally do NOT default to "Stop" if EVENT_TYPE is empty. +# Parse failures should not trigger completion notifications. +# The server will ignore requests with missing eventType (forward compatibility). + +# Map UserPromptSubmit to Start for simpler handling +[ "$EVENT_TYPE" = "UserPromptSubmit" ] && EVENT_TYPE="Start" + +# If no event type was found, skip the notification +# This prevents parse failures from causing false completion notifications +[ -z "$EVENT_TYPE" ] && exit 0 + +# Timeouts prevent blocking agent completion if notification server is unresponsive +curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ + --connect-timeout 1 --max-time 2 \ + --data-urlencode "paneId=$SUPERSET_PANE_ID" \ + --data-urlencode "tabId=$SUPERSET_TAB_ID" \ + --data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \ + --data-urlencode "eventType=$EVENT_TYPE" \ + --data-urlencode "env=$SUPERSET_ENV" \ + --data-urlencode "version=$SUPERSET_HOOK_VERSION" \ + > /dev/null 2>&1 diff --git a/biome.jsonc b/biome.jsonc index ff7e9f3a995..aec415e7d48 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -6,7 +6,7 @@ "useIgnoreFile": true }, "files": { - "includes": ["**", "!**/drizzle", "!**/*.template.js"] + "includes": ["**", "!**/drizzle", "!**/*.template.js", "!**/*.template.sh"] }, "formatter": { "formatWithErrors": true From 4cd7df38735b8c1046280bbac0840af7de8c902d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 6 Jan 2026 12:11:55 -0800 Subject: [PATCH 23/24] typecheck --- apps/desktop/tsconfig.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 39ebe60eb7e..00b8bc816f2 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -14,5 +14,11 @@ "electron-builder.ts", "index.d.ts" ], - "exclude": ["node_modules", "dist", "dist-electron", "release"] + "exclude": [ + "node_modules", + "dist", + "dist-electron", + "release", + "src/**/templates/**" + ] } From f133d5a83aa1ade82c0acf44fe84a542c65c4ead Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 6 Jan 2026 12:24:50 -0800 Subject: [PATCH 24/24] fix potential race condition --- .../ContentView/TabsContent/Terminal/Terminal.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index d0866f4491d..94e6fafdffb 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -228,7 +228,12 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { // Clear transient pane status on terminal exit // "working" and "permission" should clear (agent no longer active) // "review" should persist (user needs to see completed work) - if (pane?.status === "working" || pane?.status === "permission") { + // Use store getter to get fresh pane status at event time (not stale closure) + const currentPane = useTabsStore.getState().panes[paneId]; + if ( + currentPane?.status === "working" || + currentPane?.status === "permission" + ) { setPaneStatus(paneId, "idle"); } }