Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e6acf0f
feat(desktop): add 3-color workspace status indicators
andreasasprou Jan 5, 2026
c6c9241
fix(desktop): use StatusIndicator in workspace sidebar instead of har…
andreasasprou Jan 6, 2026
0cd3808
fix(desktop): use correct OpenCode event types for status indicators
andreasasprou Jan 6, 2026
03fe2ce
fix(desktop): clear pane status on terminal exit to fix stuck indicators
andreasasprou Jan 6, 2026
a231505
fix(desktop): clear status on ESC/Ctrl+C to handle agent interrupts
andreasasprou Jan 6, 2026
12a6b11
fix(desktop): use session.status event for OpenCode busy detection
andreasasprou Jan 6, 2026
12688e0
chore(desktop): add debug logging to OpenCode plugin for idle detection
andreasasprou Jan 6, 2026
43c908d
chore(desktop): add debug logging to trace OpenCode Stop notification…
andreasasprou Jan 6, 2026
d542032
feat(desktop): add debug logging utility with SUPERSET_DEBUG flag
andreasasprou Jan 6, 2026
f3f5dc9
fix(desktop): remove unused needsAttention variable
andreasasprou Jan 6, 2026
0906d4c
fix(desktop): harden OpenCode plugin against race conditions (v8)
andreasasprou Jan 6, 2026
8fe8b15
chore: remove thoughts/ directory from PR (internal artifacts)
andreasasprou Jan 6, 2026
765edf1
fix(desktop): address PR review feedback
andreasasprou Jan 6, 2026
6a815d8
chore: revert .gitignore change
andreasasprou Jan 6, 2026
07189fc
fix(desktop): reset rootSessionID after Stop for new sessions
andreasasprou Jan 6, 2026
0467bc3
chore: remove thoughts/ from PR again
andreasasprou Jan 6, 2026
250ab5c
refactor(desktop): centralize status priority logic
andreasasprou Jan 6, 2026
7c7a7b7
chore: fix import organization
andreasasprou Jan 6, 2026
c3e32f9
Use external file instead of inline content
Kitenite Jan 6, 2026
20a05ed
Inject plugin marker
Kitenite Jan 6, 2026
9a78b89
refactor and clean up comments
Kitenite Jan 6, 2026
8c2a9a3
Move notify into its own file
Kitenite Jan 6, 2026
4cd7df3
typecheck
Kitenite Jan 6, 2026
f133d5a
fix potential race condition
Kitenite Jan 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,4 @@ next-env.d.ts
.env

# Reference material downloaded for agents
examples
examples
99 changes: 99 additions & 0 deletions apps/desktop/docs/EXTERNAL_FILES.md
Original file line number Diff line number Diff line change
@@ -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
19 changes: 11 additions & 8 deletions apps/desktop/src/lib/trpc/routers/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { observable } from "@trpc/server/observable";
import {
type AgentCompleteEvent,
type AgentLifecycleEvent,
type NotificationIds,
notificationsEmitter,
} from "main/lib/notifications/server";
Expand All @@ -9,30 +9,33 @@ 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 };

export const createNotificationsRouter = () => {
return router({
subscribe: publicProcedure.subscription(() => {
return observable<NotificationEvent>((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);
};
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/trpc/routers/ui-state/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
162 changes: 36 additions & 126 deletions apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,16 @@ 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";

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,
"templates",
"opencode-plugin.template.js",
);

const REAL_BINARY_RESOLVER = `find_real_binary() {
local name="$1"
Expand Down Expand Up @@ -56,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
Expand All @@ -72,6 +77,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 }] },
Expand Down Expand Up @@ -135,114 +141,12 @@ exec "$REAL_BIN" "$@"
}

export function getOpenCodePluginContent(notifyPath: string): string {
// Build "${" via char codes to avoid JS template literal interpolation in generated code
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andreasasprou fyi i moved this into a js file and uses template replace instead. we get the benefit of linting and making the script more maintainable

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.idle, session.error, and permission.ask events.",
" *",
" * IMPORTANT: Subagent/Background Task 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.",
" *",
" * @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;",
"",
" // Only run inside a Superset terminal session",
" if (!process?.env?.SUPERSET_TAB_ID) return {};",
"",
` const notifyPath = "${notifyPath}";`,
"",
" /**",
" * 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 });",
" try {",
shellLine,
" } catch {",
" // Best-effort only; do not break the agent if notification fails",
" }",
" };",
"",
" /**",
" * Checks if a session is a child/subagent session by looking up its parentID.",
" *",
" * 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",
" */",
" const isChildSession = async (sessionID) => {",
" if (!sessionID || !client?.session?.list) return false;",
" 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;",
" }",
" };",
"",
" return {",
" event: async ({ event }) => {",
" // Handle session completion events",
' if (event.type === "session.idle" || event.type === "session.error") {',
" const sessionID = event.properties?.sessionID;",
"",
" // Skip notifications for child/subagent sessions",
" // This prevents notification spam when background agents complete",
" if (await isChildSession(sessionID)) {",
" return;",
" }",
"",
' await notify("Stop");',
" }",
" },",
' "permission.ask": async (_permission, output) => {',
' if (output.status === "ask") {',
' await notify("PermissionRequest");',
" }",
" },",
" };",
"};",
"",
].join("\n");
const template = fs.readFileSync(OPENCODE_PLUGIN_TEMPLATE_PATH, "utf-8");
return template
.replace("{{MARKER}}", OPENCODE_PLUGIN_MARKER)
.replace("{{NOTIFY_PATH}}", notifyPath);
}

/**
* Creates the Claude Code settings JSON file with notification hooks
*/
function createClaudeSettings(): string {
const settingsPath = getClaudeSettingsPath();
const notifyPath = getNotifyScriptPath();
Expand All @@ -252,9 +156,6 @@ function createClaudeSettings(): string {
return settingsPath;
}

/**
* Creates wrapper script for Claude Code
*/
export function createClaudeWrapper(): void {
const wrapperPath = getClaudeWrapperPath();
const settingsPath = createClaudeSettings();
Expand All @@ -263,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();
Expand All @@ -275,29 +173,41 @@ export function createCodexWrapper(): void {
}

/**
* Creates OpenCode plugin file with notification hooks
* Writes to environment-specific path only, 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");
}

/**
* 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 {
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");
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) {
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");
}

/**
* Creates wrapper script for OpenCode
*/
export function createOpenCodeWrapper(): void {
const wrapperPath = getOpenCodeWrapperPath();
const script = buildOpenCodeWrapperScript(OPENCODE_CONFIG_DIR);
Expand Down
Loading