diff --git a/apps/desktop/plans/20260507-1659-agent-lifecycle-hooks-terminal-binding.md b/apps/desktop/plans/20260507-1659-agent-lifecycle-hooks-terminal-binding.md new file mode 100644 index 00000000000..714b33ebce8 --- /dev/null +++ b/apps/desktop/plans/20260507-1659-agent-lifecycle-hooks-terminal-binding.md @@ -0,0 +1,225 @@ +# Agent Identity in Lifecycle Hooks → Icon in Terminal Pane (v2) + +**Status:** Draft +**Scope:** v2 only + +## Goal + +When a CLI agent (Claude, Codex, Gemini, …) is running inside a v2 terminal and we can detect it via the existing lifecycle hook, **show the agent's icon in that pane's header**. Hide it when no agent is running. + +That's the user-facing feature. To get there, generalize the hook contract so every agent reports a small **agent identity object** — primarily the `agentId` (matching our existing agent model), plus optional `sessionId` and room for more fields later — keyed by `terminalId`. The icon is the first consumer; future surfaces (resume UX, chat ↔ terminal cross-link, observability) reuse the same shape without further protocol churn. + +## Why + +- Users have no way to tell at a glance whether a terminal is "just a shell" or "Claude is alive in there." The icon answers that instantly. +- All assets and infra already exist; the missing piece is propagating the agent identity through the hook. + +## Existing agent model (use, don't reinvent) + +The repo already has a canonical model for agent identity. Reuse it: + +- `BuiltinAgentId` — `"claude" | "amp" | "codex" | "gemini" | "mastracode" | "opencode" | "pi" | "copilot" | "cursor-agent" | "superset"`. Defined in `packages/shared/src/agent-catalog.ts:23` from `BUILTIN_TERMINAL_AGENTS` in `packages/shared/src/builtin-terminal-agents.ts:59`. +- `AgentDefinitionId` — `BuiltinAgentId | \`custom:${string}\`` (`agent-catalog.ts:24`). User-customized definitions get the `custom:` prefix. +- `HostAgentPreset.presetId` (`packages/shared/src/host-agent-presets.ts:4`) uses these same strings for terminal presets. +- `PRESET_ICONS` (`packages/ui/src/assets/icons/preset-icons/index.ts`) is keyed by these same strings — `usePresetIcon("claude")` already does the right thing. + +Naming convention in this doc: **`agentId`** = a `BuiltinAgentId` (the wrapper-level identity). Avoid the word `kind` here — `AgentKind` already means `"terminal" | "chat"` in `packages/shared/src/agent-definition.ts:8`, so reusing it would collide. + +`agentDefinitionId` (the user-def-level identity, e.g. `custom:my-claude-no-thinking`) is **out of scope for this PR** — wrappers register hooks at the binary level (`~/.claude/settings.json` fires for *all* `claude` invocations regardless of which custom def was launched), so the wrapper can only stamp the builtin `agentId`. Plumbing the definition id requires the launch path to set a per-invocation env var; noted as a future extension below. + +## Current state + +End-to-end lifecycle pipe already works, keyed by `terminalId`: + +| Step | Where | +| --- | --- | +| `SUPERSET_TERMINAL_ID` + hook URL injected into PTY env | `packages/host-service/src/terminal/env.ts:183,195` | +| Hook script POSTs `{terminalId, eventType}` to host-service | `apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh:91-107` | +| `notifications.hook` broadcasts `agent:lifecycle` over WS | `packages/host-service/src/trpc/router/notifications/notifications.ts:31` | +| Renderer keys pane status (working/permission/review/idle) by `terminalId` | `apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/{lifecycleEvents,statusTransitions}.ts` | +| Per-agent icons exist | `packages/ui/src/assets/icons/preset-icons/index.ts` (`PRESET_ICONS`: claude, codex, gemini, opencode, copilot, cursor-agent, amp, pi, mastracode) | +| Pane header has an extras slot | `apps/desktop/src/renderer/.../TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx` | + +The hook currently carries `eventType` but not which agent fired it. + +## Generic shape + +One thing every layer agrees on. Define once, share across host-service / main / renderer: + +```ts +import type { BuiltinAgentId, AgentDefinitionId } from "@superset/shared"; + +// Reported by hook, broadcast over WS, stored in renderer state. +// Everything besides `agentId` is optional — a hook that knows only the +// agent is still useful; fields are additive forever. +export interface AgentIdentity { + agentId: BuiltinAgentId; // "claude" | "codex" | "gemini" | … + sessionId?: string; // agent-native session id when the hook payload exposes one + definitionId?: AgentDefinitionId; // future: stamped by the launch path, not the wrapper + // future-friendly: model?, version?, transcriptPath?, … add later without breaking callers +} +``` + +Why nest under one object instead of flat fields: + +- Single name to grep, one type to extend. +- Layers that don't care about fields beyond `agentId` (the icon UI) ignore the rest. +- `terminalId` stays the key on every map; `AgentIdentity` is the value. + +We pass identity through unchanged at every boundary. No enum gating on `agentId` at the wire level — the receiver accepts any string and the renderer's `usePresetIcon` returns `undefined` for unknowns, so an agent ships by adding to `BUILTIN_TERMINAL_AGENTS` + `PRESET_ICONS` + a wrapper. No schema migration. + +## Design + +### 1. Each wrapper stamps its `agentId` + +Each agent wrapper writes its hook command line. Inject one env var there — the hook script doesn't need to sniff JSON shape: + +```sh +SUPERSET_AGENT_ID=claude $SUPERSET_HOME_DIR/hooks/notify.sh +``` + +The value is a `BuiltinAgentId`. Per-wrapper assignments: + +- `agent-wrappers-claude-codex-opencode.ts` → `claude` / `codex` / `opencode` +- `agent-wrappers-gemini.ts` → `gemini` +- `agent-wrappers-cursor.ts` → `cursor-agent` +- `agent-wrappers-copilot.ts` → `copilot` +- `agent-wrappers-amp.ts` → `amp` +- `agent-wrappers-pi.ts` → `pi` +- `agent-wrappers-mastra.ts` → `mastracode` +- `agent-wrappers-droid.ts` → `droid` (no preset entry today; either skip the icon for droid or add it to `BUILTIN_TERMINAL_AGENTS` first) + +`SUPERSET_AGENT_ID` is the only env var the wrapper needs to set. `sessionId` is parsed out of the agent's own JSON payload at hook time (next step). + +### 2. Hook script forwards identity + +`notify-hook.template.sh` already parses `HOOK_SESSION_ID` from the agent JSON (line 15) and uses it on the v1 fallback (line 119). Carry it on the v2 path too. + +In `notify-hook.template.sh:91-107` (v2 branch): + +```sh +# Build identity object inline so missing fields naturally drop out as empty strings. +PAYLOAD="{\"json\":{ + \"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\", + \"eventType\":\"$(json_escape "$EVENT_TYPE")\", + \"agent\":{ + \"agentId\":\"$(json_escape "$SUPERSET_AGENT_ID")\", + \"sessionId\":\"$(json_escape "$HOOK_SESSION_ID")\" + } +}}" +``` + +Receiver coerces empty strings → undefined so missing fields don't poison downstream logic. Older shells with a cached pre-change script still post the v2 minimal payload; the receiver tolerates absence of `agent` entirely. + +When other agents (Codex, Gemini, …) expose a session id under a different JSON key, extend the extraction in the script the same way `HOOK_SESSION_ID` is parsed today — the result still maps into `agent.sessionId`. + +### 3. tRPC schema + broadcast + +`packages/host-service/src/trpc/router/notifications/notifications.ts`: + +```ts +const agentIdentitySchema = z + .object({ + agentId: z.string().min(1), // BuiltinAgentId at the type level; string at the wire + sessionId: z.string().min(1).optional(), + definitionId: z.string().min(1).optional(), + }) + .optional(); + +const hookInput = z.object({ + terminalId: z.string().optional(), + eventType: z.string().optional(), + agent: agentIdentitySchema, // NEW +}); +``` + +Receiver normalizes (drop empty strings → undefined), then: + +```ts +ctx.eventBus.broadcastAgentLifecycle({ + workspaceId, + eventType, + terminalId, + agent: input.agent, // NEW — pass through verbatim + occurredAt: Date.now(), +}); +``` + +Add `agent?: AgentIdentity` to `AgentLifecycleMessage` (`packages/host-service/src/events/types.ts:25`) and `AgentLifecyclePayload` in `@superset/workspace-client`. Define `AgentIdentity` once — best home is `packages/shared/src/agent-identity.ts` since `BuiltinAgentId` lives in `@superset/shared` already — and re-export from `@superset/workspace-client` so host-service / main / renderer share one source of truth. + +### 4. Renderer: live binding store + +New small zustand slice — no persistence, generic over the identity shape: + +```ts +// renderer/stores/v2-agent-bindings/store.ts +import type { AgentIdentity } from "@superset/workspace-client"; + +type Binding = { identity: AgentIdentity; lastEventAt: number }; + +interface V2AgentBindingState { + byTerminalId: Record; + set(terminalId: string, identity: AgentIdentity, at: number): void; + clear(terminalId: string): void; +} +``` + +Wire in `HostNotificationSubscriber` (`...V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx`): + +- On `agent:lifecycle` with `payload.agent`: `set(terminalId, payload.agent, occurredAt)`. + Includes `Stop` events — the agent is still the live agent until the terminal exits or a different `agentId`/`sessionId` arrives. This avoids the icon flickering off between user prompts. +- On `terminal:lifecycle` `exit`: `clear(terminalId)`. + +A subsequent event with a different `agent.sessionId` (or different `agentId`) for the same terminal **replaces** the binding — last-seen wins. We retain `sessionId` for future surfaces (e.g. "resume this session") even though the icon only reads `agentId`. + +### 5. Render the icon + +`TerminalHeaderExtras.tsx`: + +```tsx +const binding = useV2AgentBindingStore(s => s.byTerminalId[data.terminalId]); +const agentId = binding?.identity.agentId; +const label = agentId ? BUILTIN_AGENT_LABELS[agentId] : undefined; +const iconSrc = usePresetIcon(agentId ?? ""); + +return ( +
+ {iconSrc && agentId ? ( + {label + ) : null} + +
+); +``` + +`usePresetIcon` already handles dark/light variants and returns `undefined` for unknown ids → nothing renders. `BUILTIN_AGENT_LABELS` (from `@superset/shared/agent-catalog`) gives the human-readable name for tooltip / a11y. Safe default for any future agent id that ships before its icon does. + +## What this does not do + +- No persistence — refresh and the binding rebuilds on the next lifecycle event (next agent message). For the first second after reload there's no icon; acceptable. +- No multi-agent-per-terminal display — last-seen identity wins. +- No chat-pane / session-resume integration. The `sessionId` field is *captured* so those features can land later without a protocol round trip; this PR doesn't expose it in the UI. +- No `definitionId` resolution. The wrapper-level hook can't tell which user-customized definition launched (e.g. `custom:my-claude-no-thinking` vs builtin `claude`) — they share a binary and a `~/.claude/settings.json`. To plumb it later: have the launch path (`packages/shared/src/agent-launch-request.ts` and callers) inject `SUPERSET_AGENT_DEFINITION_ID=` into the spawned command's env; the hook script picks it up and adds `definitionId` to the identity object. Field is reserved in the schema today. + +## Test plan + +- `notify-hook.test.ts` — v2 payload includes `agent.agentId` when env var set, includes `agent.sessionId` when present in stdin JSON, omits the whole `agent` object when neither is set. +- `notifications.test.ts` — `agent` passes through to broadcast; empty-string fields normalized to undefined; identity missing entirely → broadcast still fires (icon just won't render). +- Renderer unit — store stores `{agentId, sessionId}` on `Start`, retains it on `Stop`, replaces on a different `sessionId` or `agentId`, clears on `terminal:lifecycle exit`. +- Manual — open terminal, run `claude`, header shows the Claude glyph; `/exit`, run `codex`, header switches to Codex; close terminal, icon goes away. +- Mutation check — flip `SUPERSET_AGENT_ID=claude` to empty in the wrapper template; renderer test asserting the icon renders should fail. + +## Rollout + +One PR can ship the whole thing — it's small: + +1. Wrapper env injection (Claude first; sweep the rest in same PR or next). +2. Hook script + receiver field. +3. Renderer store + `TerminalHeaderExtras` wiring. + +Backward-compatible at every layer (all new fields optional; missing → no icon, same as today). diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-amp.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-amp.ts index 0ab3df0902f..1a1d49866c1 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-amp.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-amp.ts @@ -1,11 +1,58 @@ -import { buildWrapperScript, createWrapper } from "./agent-wrappers-common"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + buildWrapperScript, + createWrapper, + writeFileIfChanged, +} from "./agent-wrappers-common"; /** * Creates the Amp wrapper that preserves Superset's terminal environment. - * Amp does not currently expose stable hook support, so this wrapper is a - * pass-through binary shim only. + * Amp lifecycle events are registered through a system plugin; the wrapper + * exists to forward SUPERSET_* env vars into the plugin runtime. */ export function createAmpWrapper(): void { - const script = buildWrapperScript("amp", `exec "$REAL_BIN" "$@"`); + const script = buildWrapperScript("amp", `exec "$REAL_BIN" "$@"`, { + agentId: "amp", + }); createWrapper("amp", script); } + +export const AMP_PLUGIN_FILE = "superset-lifecycle.ts"; +const AMP_PLUGIN_SIGNATURE = "// Superset Amp lifecycle plugin"; +const AMP_PLUGIN_VERSION = "v3"; +export const AMP_PLUGIN_MARKER = `${AMP_PLUGIN_SIGNATURE} ${AMP_PLUGIN_VERSION}`; +const AMP_PLUGIN_TEMPLATE_PATH = path.join( + __dirname, + "templates", + "amp-plugin.template.ts", +); + +/** + * Amp loads system plugins from ~/.config/amp/plugins/*.ts. + * + * @see https://ampcode.com/manual#plugins + */ +export function getAmpGlobalPluginPath(): string { + return path.join(os.homedir(), ".config", "amp", "plugins", AMP_PLUGIN_FILE); +} + +/** + * Renders a global Amp plugin that bridges Amp's lifecycle events into the + * existing Superset notify hook. The notify hook owns v2/v1 fallback dispatch, + * so this plugin stays small and avoids duplicating mapping logic. + */ +export function getAmpPluginContent(): string { + const template = fs.readFileSync(AMP_PLUGIN_TEMPLATE_PATH, "utf-8"); + return template.replace("{{MARKER}}", AMP_PLUGIN_MARKER); +} + +export function createAmpPlugin(): void { + const pluginPath = getAmpGlobalPluginPath(); + fs.mkdirSync(path.dirname(pluginPath), { recursive: true }); + const changed = writeFileIfChanged(pluginPath, getAmpPluginContent(), 0o644); + console.log( + `[agent-setup] ${changed ? "Updated" : "Verified"} Amp lifecycle plugin`, + ); +} diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts index db889900cd5..695494edf03 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts @@ -76,9 +76,13 @@ function isPlainObject(value: unknown): value is Record { * Returns the shell command written into Claude's global hook config. * The notify path is resolved at runtime from SUPERSET_HOME_DIR so one * shared ~/.claude/settings.json works for both dev and prod installs. + * + * `SUPERSET_AGENT_ID=claude` is inlined so the v2 hook payload carries the + * wrapper-level identity even when Claude is launched outside the Superset + * wrapper (system PATH resolves to the real binary directly). */ export function getClaudeManagedHookCommand(): string { - return `[ -n "$SUPERSET_HOME_DIR" ] && [ -x "$SUPERSET_HOME_DIR/${CLAUDE_DYNAMIC_NOTIFY_RELATIVE_PATH}" ] && "$SUPERSET_HOME_DIR/${CLAUDE_DYNAMIC_NOTIFY_RELATIVE_PATH}" || true`; + return `[ -n "$SUPERSET_HOME_DIR" ] && [ -x "$SUPERSET_HOME_DIR/${CLAUDE_DYNAMIC_NOTIFY_RELATIVE_PATH}" ] && SUPERSET_AGENT_ID=claude "$SUPERSET_HOME_DIR/${CLAUDE_DYNAMIC_NOTIFY_RELATIVE_PATH}" || true`; } function isManagedClaudeHookCommand( @@ -172,6 +176,8 @@ export function getClaudeGlobalSettingsJsonContent( const managedEvents: Array<{ eventName: + | "SessionStart" + | "SessionEnd" | "UserPromptSubmit" | "Stop" | "PostToolUse" @@ -179,17 +185,21 @@ export function getClaudeGlobalSettingsJsonContent( | "PermissionRequest"; definition: ClaudeHookDefinition; }> = [ + { + eventName: "SessionStart", + definition: { hooks: [{ type: "command", command: managedHookCommand }] }, + }, + { + eventName: "SessionEnd", + definition: { hooks: [{ type: "command", command: managedHookCommand }] }, + }, { eventName: "UserPromptSubmit", - definition: { - hooks: [{ type: "command", command: managedHookCommand }], - }, + definition: { hooks: [{ type: "command", command: managedHookCommand }] }, }, { eventName: "Stop", - definition: { - hooks: [{ type: "command", command: managedHookCommand }], - }, + definition: { hooks: [{ type: "command", command: managedHookCommand }] }, }, { eventName: "PostToolUse", @@ -263,14 +273,14 @@ export function getOpenCodePluginContent(notifyPath: string): string { } /** - * Creates the Claude wrapper that forwards SUPERSET_* env vars into the agent. + * Pass-through wrapper for Claude. Hooks live in ~/.claude/settings.json + * (createClaudeSettingsJson); the wrapper exists only to forward SUPERSET_* + * env vars into the agent process tree. */ export function createClaudeWrapper(): void { - // Hooks are now written directly to ~/.claude/settings.json via - // createClaudeSettingsJson(), so the wrapper is a plain pass-through. - // We still create the wrapper so SUPERSET_* env vars flow through - // and the notify script can identify the Superset terminal context. - const script = buildWrapperScript("claude", `exec "$REAL_BIN" "$@"`); + const script = buildWrapperScript("claude", `exec "$REAL_BIN" "$@"`, { + agentId: "claude", + }); createWrapper("claude", script); } @@ -282,6 +292,7 @@ export function createCodexWrapper(): void { const script = buildWrapperScript( "codex", buildCodexWrapperExecLine(notifyPath), + { agentId: "codex" }, ); createWrapper("codex", script); } @@ -384,27 +395,27 @@ export function getCodexGlobalHooksJsonContent( existing.hooks[eventName] = filtered; } + // Inline SUPERSET_AGENT_ID like getClaudeManagedHookCommand so the v2 + // payload carries identity even when codex is launched outside the wrapper. + // Quote the path: codex executes via /bin/sh -lc, so a space in $HOME + // (e.g. "/Users/Some User/...") would otherwise word-split. + const codexCommand = `SUPERSET_AGENT_ID=codex "${notifyScriptPath}"`; + const managedEvents: Array<{ eventName: "SessionStart" | "UserPromptSubmit" | "Stop"; definition: ClaudeHookDefinition; }> = [ { eventName: "SessionStart", - definition: { - hooks: [{ type: "command", command: notifyScriptPath }], - }, + definition: { hooks: [{ type: "command", command: codexCommand }] }, }, { eventName: "UserPromptSubmit", - definition: { - hooks: [{ type: "command", command: notifyScriptPath }], - }, + definition: { hooks: [{ type: "command", command: codexCommand }] }, }, { eventName: "Stop", - definition: { - hooks: [{ type: "command", command: notifyScriptPath }], - }, + definition: { hooks: [{ type: "command", command: codexCommand }] }, }, ]; @@ -491,6 +502,7 @@ export function createOpenCodeWrapper(): void { const script = buildWrapperScript( "opencode", `export OPENCODE_CONFIG_DIR="${OPENCODE_CONFIG_DIR}"\nexec "$REAL_BIN" "$@"`, + { agentId: "opencode" }, ); createWrapper("opencode", script); } diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts index deff26ad578..b712e110ae0 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { SUPERSET_MANAGED_BINARIES } from "./desktop-agent-capabilities"; import { BIN_DIR } from "./paths"; -export const WRAPPER_MARKER = "# Superset agent-wrapper v1"; +export const WRAPPER_MARKER = "# Superset agent-wrapper v2"; export { SUPERSET_MANAGED_BINARIES }; // Dev setup (.superset/lib/setup/steps.sh) points SUPERSET_HOME_DIR at @@ -109,10 +109,24 @@ export function getWrapperPath(binaryName: string): string { return path.join(BIN_DIR, binaryName); } +export interface BuildWrapperScriptOptions { + /** + * `BuiltinAgentId` for the wrapped binary (e.g. "claude", "codex"). When + * set, the wrapper exports `SUPERSET_AGENT_ID` so the agent process and + * any hook subprocess it spawns inherit the wrapper-level identity. The + * notify-hook script forwards this into the v2 hook payload. + */ + agentId?: string; +} + export function buildWrapperScript( binaryName: string, execLine: string, + options: BuildWrapperScriptOptions = {}, ): string { + const exportAgentId = options.agentId + ? `export SUPERSET_AGENT_ID="${options.agentId}"\n\n` + : ""; return `#!/bin/bash ${WRAPPER_MARKER} # Superset wrapper for ${binaryName} @@ -124,7 +138,7 @@ if [ -z "$REAL_BIN" ]; then exit 127 fi -${execLine} +${exportAgentId}${execLine} `; } diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts index 4aba1cec9ac..1a9b34964d6 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-copilot.ts @@ -28,7 +28,7 @@ export function getCopilotHookScriptContent(): string { const template = fs.readFileSync(COPILOT_HOOK_TEMPLATE_PATH, "utf-8"); return template .replace("{{MARKER}}", COPILOT_HOOK_MARKER) - .replace(/\{\{DEFAULT_PORT\}\}/g, String(env.DESKTOP_NOTIFICATIONS_PORT)); + .replaceAll("{{DEFAULT_PORT}}", String(env.DESKTOP_NOTIFICATIONS_PORT)); } export function createCopilotHookScript(): void { @@ -83,8 +83,8 @@ export function buildCopilotWrapperExecLine(): string { const escapedJson = hooksJson.replace(/'/g, "'\\''"); return `# Copilot CLI only supports project-level hooks (.github/hooks/*.json in CWD). -# Auto-inject Superset notification hooks when running inside a Superset terminal. -if [ -n "$SUPERSET_TAB_ID" ] && [ -f "${hookScriptPath}" ]; then +# Auto-inject Superset notification hooks when running inside a v2 Superset terminal. +if [ -n "$SUPERSET_TERMINAL_ID" ] && [ -f "${hookScriptPath}" ]; then COPILOT_HOOKS_DIR=".github/hooks" COPILOT_HOOK_FILE="$COPILOT_HOOKS_DIR/superset-notify.json" @@ -103,6 +103,8 @@ exec "$REAL_BIN" "$@"`; } export function createCopilotWrapper(): void { - const script = buildWrapperScript("copilot", buildCopilotWrapperExecLine()); + const script = buildWrapperScript("copilot", buildCopilotWrapperExecLine(), { + agentId: "copilot", + }); createWrapper("copilot", script); } diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-cursor.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-cursor.ts index f5b8580bd56..4e47760df52 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-cursor.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-cursor.ts @@ -14,7 +14,7 @@ import { HOOKS_DIR } from "./paths"; export const CURSOR_HOOK_SCRIPT_NAME = "cursor-hook.sh"; const CURSOR_HOOK_SIGNATURE = "# Superset cursor hook"; -const CURSOR_HOOK_VERSION = "v1"; +const CURSOR_HOOK_VERSION = "v2"; export const CURSOR_HOOK_MARKER = `${CURSOR_HOOK_SIGNATURE} ${CURSOR_HOOK_VERSION}`; const CURSOR_HOOK_TEMPLATE_PATH = path.join( @@ -46,7 +46,7 @@ export function getCursorHookScriptContent(): string { const template = fs.readFileSync(CURSOR_HOOK_TEMPLATE_PATH, "utf-8"); return template .replace("{{MARKER}}", CURSOR_HOOK_MARKER) - .replace(/\{\{DEFAULT_PORT\}\}/g, String(env.DESKTOP_NOTIFICATIONS_PORT)); + .replaceAll("{{DEFAULT_PORT}}", String(env.DESKTOP_NOTIFICATIONS_PORT)); } /** @@ -75,6 +75,8 @@ export function getCursorHooksJsonContent(hookScriptPath: string): string { } const ourHooks: Record = { + sessionStart: { command: `${hookScriptPath} SessionStart` }, + sessionEnd: { command: `${hookScriptPath} SessionEnd` }, beforeSubmitPrompt: { command: `${hookScriptPath} Start` }, stop: { command: `${hookScriptPath} Stop` }, beforeShellExecution: { @@ -112,7 +114,9 @@ export function createCursorHookScript(): void { } export function createCursorAgentWrapper(): void { - const script = buildWrapperScript("cursor-agent", `exec "$REAL_BIN" "$@"`); + const script = buildWrapperScript("cursor-agent", `exec "$REAL_BIN" "$@"`, { + agentId: "cursor-agent", + }); createWrapper("cursor-agent", script); } diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-droid.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-droid.ts index e0ba5fd9311..7cfd847f62b 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-droid.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-droid.ts @@ -92,12 +92,18 @@ function removeManagedHooksFromDefinition( }; } +function quoteShellPath(filePath: string): string { + return `'${filePath.replaceAll("'", "'\\''")}'`; +} + export function getDroidSettingsJsonPath(): string { return path.join(os.homedir(), ".factory", "settings.json"); } export function createDroidWrapper(): void { - const script = buildWrapperScript("droid", `exec "$REAL_BIN" "$@"`); + const script = buildWrapperScript("droid", `exec "$REAL_BIN" "$@"`, { + agentId: "droid", + }); createWrapper("droid", script); } @@ -119,33 +125,53 @@ export function getDroidSettingsJsonContent( existing.hooks = {}; } + const managedHookCommand = `SUPERSET_AGENT_ID=droid ${quoteShellPath(notifyScriptPath)}`; + const managedEvents: Array<{ - eventName: "UserPromptSubmit" | "Notification" | "Stop" | "PostToolUse"; + eventName: + | "SessionStart" + | "SessionEnd" + | "UserPromptSubmit" + | "Notification" + | "Stop" + | "PostToolUse"; definition: DroidHookDefinition; }> = [ + { + eventName: "SessionStart", + definition: { + hooks: [{ type: "command", command: managedHookCommand }], + }, + }, + { + eventName: "SessionEnd", + definition: { + hooks: [{ type: "command", command: managedHookCommand }], + }, + }, { eventName: "UserPromptSubmit", definition: { - hooks: [{ type: "command", command: notifyScriptPath }], + hooks: [{ type: "command", command: managedHookCommand }], }, }, { eventName: "Notification", definition: { - hooks: [{ type: "command", command: notifyScriptPath }], + hooks: [{ type: "command", command: managedHookCommand }], }, }, { eventName: "Stop", definition: { - hooks: [{ type: "command", command: notifyScriptPath }], + hooks: [{ type: "command", command: managedHookCommand }], }, }, { eventName: "PostToolUse", definition: { matcher: "*", - hooks: [{ type: "command", command: notifyScriptPath }], + hooks: [{ type: "command", command: managedHookCommand }], }, }, ]; diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts index 8e3b7efa282..d1ef6cedd77 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-gemini.ts @@ -14,7 +14,7 @@ import { HOOKS_DIR } from "./paths"; export const GEMINI_HOOK_SCRIPT_NAME = "gemini-hook.sh"; const GEMINI_HOOK_SIGNATURE = "# Superset gemini hook"; -const GEMINI_HOOK_VERSION = "v1"; +const GEMINI_HOOK_VERSION = "v2"; export const GEMINI_HOOK_MARKER = `${GEMINI_HOOK_SIGNATURE} ${GEMINI_HOOK_VERSION}`; const GEMINI_HOOK_TEMPLATE_PATH = path.join( @@ -31,6 +31,7 @@ interface GeminiHookConfig { interface GeminiHookDefinition { matcher?: string; + command?: string; hooks?: GeminiHookConfig[]; [key: string]: unknown; } @@ -52,7 +53,7 @@ export function getGeminiHookScriptContent(): string { const template = fs.readFileSync(GEMINI_HOOK_TEMPLATE_PATH, "utf-8"); return template .replace("{{MARKER}}", GEMINI_HOOK_MARKER) - .replace(/\{\{DEFAULT_PORT\}\}/g, String(env.DESKTOP_NOTIFICATIONS_PORT)); + .replaceAll("{{DEFAULT_PORT}}", String(env.DESKTOP_NOTIFICATIONS_PORT)); } /** @@ -80,7 +81,14 @@ export function getGeminiSettingsJsonContent(hookScriptPath: string): string { existing.hooks = {}; } - const eventNames = ["BeforeAgent", "AfterAgent", "AfterTool"]; + // HookEventName values from gemini-cli's packages/core/src/hooks/types.ts. + const eventNames = [ + "SessionStart", + "SessionEnd", + "BeforeAgent", + "AfterAgent", + "AfterTool", + ]; for (const eventName of eventNames) { const current = existing.hooks[eventName]; @@ -93,6 +101,10 @@ export function getGeminiSettingsJsonContent(hookScriptPath: string): string { current, desired: desiredEntries, isManaged: (definition: GeminiHookDefinition) => + isSupersetManagedHookCommand( + definition.command, + GEMINI_HOOK_SCRIPT_NAME, + ) || Boolean( definition.hooks?.some( (hook) => @@ -126,7 +138,9 @@ export function createGeminiHookScript(): void { } export function createGeminiWrapper(): void { - const script = buildWrapperScript("gemini", `exec "$REAL_BIN" "$@"`); + const script = buildWrapperScript("gemini", `exec "$REAL_BIN" "$@"`, { + agentId: "gemini", + }); createWrapper("gemini", script); } diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-mastra.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-mastra.ts index 191750c60ca..975897562f7 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-mastra.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-mastra.ts @@ -44,7 +44,9 @@ export function getMastraGlobalHooksJsonPath(): string { } export function createMastraWrapper(): void { - const script = buildWrapperScript("mastracode", `exec "$REAL_BIN" "$@"`); + const script = buildWrapperScript("mastracode", `exec "$REAL_BIN" "$@"`, { + agentId: "mastracode", + }); createWrapper("mastracode", script); } @@ -66,8 +68,15 @@ export function getMastraHooksJsonContent(notifyScriptPath: string): string { ); } - const notifyCommand = `bash ${quoteShellPath(notifyScriptPath)}`; - const managedEvents = ["UserPromptSubmit", "Stop", "PostToolUse"] as const; + const notifyCommand = `SUPERSET_AGENT_ID=mastracode bash ${quoteShellPath(notifyScriptPath)}`; + // Session lifecycle drives the pane icon binding; per-prompt drives status. + const managedEvents = [ + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "Stop", + "PostToolUse", + ] as const; for (const eventName of managedEvents) { const current = existing[eventName]; diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts index e92e9aa8e9b..636061b7951 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import { execFileSync } from "node:child_process"; import { chmodSync, + existsSync, mkdirSync, readFileSync, rmSync, @@ -56,6 +57,8 @@ mock.module("node:os", () => ({ })); const { + AMP_PLUGIN_MARKER, + createAmpPlugin, createAmpWrapper, buildCodexWrapperExecLine, buildCopilotWrapperExecLine, @@ -63,6 +66,7 @@ const { createClaudeSettingsJson, createCodexHooksJson, createCodexWrapper, + CURSOR_HOOK_MARKER, createDroidSettingsJson, createDroidWrapper, createMastraWrapper, @@ -73,6 +77,9 @@ const { getCursorHooksJsonContent, getCopilotHookScriptPath, getDroidSettingsJsonContent, + GEMINI_HOOK_MARKER, + getAmpGlobalPluginPath, + getAmpPluginContent, getGeminiSettingsJsonContent, getMastraHooksJsonContent, getPiExtensionContent, @@ -166,7 +173,7 @@ describe("agent-wrappers copilot", () => { env: { ...process.env, PATH: `${TEST_BIN_DIR}:${realBinDir}:${process.env.PATH || ""}`, - SUPERSET_TAB_ID: "tab-1", + SUPERSET_TERMINAL_ID: "terminal-1", }, encoding: "utf-8", }); @@ -176,36 +183,32 @@ describe("agent-wrappers copilot", () => { expect(updated).not.toContain("/tmp/old-hook.sh"); }); - it("injects codex start + permission watchers and completion notifications in wrapper", () => { + it("tails codex's process-scoped TUI session log to drive Start events", () => { createCodexWrapper(); const wrapperPath = path.join(TEST_BIN_DIR, "codex"); const wrapper = readFileSync(wrapperPath, "utf-8"); - expect(wrapper).toContain("export CODEX_TUI_RECORD_SESSION=1"); - expect(wrapper).toContain('"msg":{"type":"task_started"'); - expect(wrapper).toContain('_superset_last_turn_id=""'); - expect(wrapper).toContain('_superset_last_approval_id=""'); - expect(wrapper).toContain('_superset_last_exec_call_id=""'); - expect(wrapper).toContain("_superset_approval_fallback_seq=0"); - expect(wrapper).toContain("_superset_emit_event()"); - expect(wrapper).toContain("_superset_turn_id=$(printf"); - expect(wrapper).toContain("_superset_approval_id=$(printf"); - expect(wrapper).toContain("_superset_exec_call_id=$(printf"); - expect(wrapper).toContain('awk -F\'"turn_id":"\''); - expect(wrapper).toContain('"msg":{"type":"exec_command_begin"'); - expect(wrapper).toContain('_approval_request"'); - expect(wrapper).toContain( - `approval_request_\${_superset_approval_fallback_seq}`, - ); - expect(wrapper).toContain('awk -F\'"approval_id":"\''); - expect(wrapper).toContain('_superset_emit_event "Start"'); - expect(wrapper).toContain('_superset_emit_event "PermissionRequest"'); expect(wrapper).toContain( - `"$REAL_BIN" --enable codex_hooks -c 'notify=["bash","${path.join(TEST_HOOKS_DIR, "notify.sh")}"]' "$@"`, + `"$REAL_BIN" --enable hooks -c 'notify=["bash","${path.join(TEST_HOOKS_DIR, "notify.sh")}"]' "$@"`, ); - expect(wrapper).toContain("SUPERSET_CODEX_START_WATCHER_PID"); - expect(wrapper).toContain('kill "$SUPERSET_CODEX_START_WATCHER_PID"'); + expect(wrapper).toContain('export SUPERSET_AGENT_ID="codex"'); + + expect(wrapper).toContain("# Superset agent-wrapper v2"); + + // Native hooks remain enabled, but the process-scoped TUI session log is + // the reliable Start signal for installed Codex TUI builds. + expect(wrapper).toContain("SUPERSET_CODEX_SESSION_WATCHER_PID"); + expect(wrapper).toContain("CODEX_TUI_RECORD_SESSION"); + expect(wrapper).toContain("CODEX_TUI_SESSION_LOG_PATH"); + expect(wrapper).toContain("SUPERSET_TERMINAL_ID$SUPERSET_TAB_ID"); + expect(wrapper).not.toContain("rollout-*.jsonl"); + expect(wrapper).not.toContain("_superset_sessions_dir"); + expect(wrapper).not.toContain("$" + "{CODEX_HOME:-$HOME/.codex}"); + expect(wrapper).toContain("SUPERSET_HOOK_DEBUG_LOG"); + expect(wrapper).toContain("tail -n +1 -F"); + expect(wrapper).toContain('"UserTurn"'); + expect(wrapper).toContain("_approval_request"); const execLine = buildCodexWrapperExecLine( path.join(TEST_HOOKS_DIR, "notify.sh"), @@ -214,7 +217,7 @@ describe("agent-wrappers copilot", () => { expect(wrapper).toContain(execLine); }); - it("forwards codex_hooks enablement through the codex wrapper for manual launches", () => { + it("forwards hooks enablement through the codex wrapper for manual launches", () => { const realBinDir = path.join(TEST_ROOT, "real-bin"); const realCodex = path.join(realBinDir, "codex"); const wrapperPath = path.join(TEST_BIN_DIR, "codex"); @@ -237,7 +240,7 @@ exit 0 env: { ...process.env, PATH: `${TEST_BIN_DIR}:${realBinDir}:${process.env.PATH || ""}`, - SUPERSET_TAB_ID: "tab-1", + SUPERSET_TERMINAL_ID: "terminal-1", }, encoding: "utf-8", }); @@ -245,7 +248,7 @@ exit 0 expect(readFileSync(argsFile, "utf-8")).toBe( `${[ "--enable", - "codex_hooks", + "hooks", "-c", `notify=["bash","${path.join(TEST_HOOKS_DIR, "notify.sh")}"]`, "exec", @@ -254,6 +257,179 @@ exit 0 ); }); + it("emits codex Start from the wrapper-owned TUI session log", () => { + const realBinDir = path.join(TEST_ROOT, "real-bin"); + const realCodex = path.join(realBinDir, "codex"); + const wrapperPath = path.join(TEST_BIN_DIR, "codex"); + const notifyPath = path.join(TEST_HOOKS_DIR, "notify.sh"); + const notifyCapturePath = path.join(TEST_ROOT, "codex-notify-events.txt"); + const debugLogPath = path.join(TEST_ROOT, "codex-debug.log"); + + mkdirSync(realBinDir, { recursive: true }); + mkdirSync(TEST_HOOKS_DIR, { recursive: true }); + writeFileSync( + notifyPath, + `#!/bin/bash +printf '%s\n' "$1" >> "$NOTIFY_CAPTURE_PATH" +exit 0 +`, + { mode: 0o755 }, + ); + chmodSync(notifyPath, 0o755); + writeFileSync( + realCodex, + `#!/bin/bash +set -eu +: > "$CODEX_TUI_SESSION_LOG_PATH" +sleep 0.3 +printf '{"dir":"from_tui","kind":"op","payload":{"UserTurn":{"items":[]}}}\n' >> "$CODEX_TUI_SESSION_LOG_PATH" +sleep 0.3 +exit 0 +`, + { mode: 0o755 }, + ); + chmodSync(realCodex, 0o755); + + createCodexWrapper(); + + execFileSync(wrapperPath, [], { + env: { + ...process.env, + NOTIFY_CAPTURE_PATH: notifyCapturePath, + PATH: `${TEST_BIN_DIR}:${realBinDir}:${process.env.PATH || ""}`, + SUPERSET_DEBUG_HOOKS: "1", + SUPERSET_HOOK_DEBUG_LOG: debugLogPath, + SUPERSET_TERMINAL_ID: "terminal-1", + }, + encoding: "utf-8", + }); + + const notifications = readFileSync(notifyCapturePath, "utf-8"); + expect(notifications).toContain('{"hook_event_name":"Start"}'); + expect(notifications).not.toContain('{"hook_event_name":"Stop"}'); + + const debugLog = readFileSync(debugLogPath, "utf-8"); + expect(debugLog).toContain("watching session="); + expect(debugLog).toContain("emitting Start"); + }); + + it("emits codex Start from legacy TUI session logs with v1 tab context", () => { + const realBinDir = path.join(TEST_ROOT, "real-bin"); + const realCodex = path.join(realBinDir, "codex"); + const wrapperPath = path.join(TEST_BIN_DIR, "codex"); + const notifyPath = path.join(TEST_HOOKS_DIR, "notify.sh"); + const notifyCapturePath = path.join( + TEST_ROOT, + "codex-legacy-notify-events.txt", + ); + const debugLogPath = path.join(TEST_ROOT, "codex-legacy-debug.log"); + + mkdirSync(realBinDir, { recursive: true }); + mkdirSync(TEST_HOOKS_DIR, { recursive: true }); + writeFileSync( + notifyPath, + `#!/bin/bash +printf '%s\n' "$1" >> "$NOTIFY_CAPTURE_PATH" +exit 0 +`, + { mode: 0o755 }, + ); + chmodSync(notifyPath, 0o755); + writeFileSync( + realCodex, + `#!/bin/bash +set -eu +: > "$CODEX_TUI_SESSION_LOG_PATH" +sleep 0.3 +printf '{"dir":"from_tui","kind":"op","payload":{"UserTurn":{"items":[]}}}\n' >> "$CODEX_TUI_SESSION_LOG_PATH" +sleep 0.3 +exit 0 +`, + { mode: 0o755 }, + ); + chmodSync(realCodex, 0o755); + + createCodexWrapper(); + + execFileSync(wrapperPath, [], { + env: { + ...process.env, + NOTIFY_CAPTURE_PATH: notifyCapturePath, + PATH: `${TEST_BIN_DIR}:${realBinDir}:${process.env.PATH || ""}`, + SUPERSET_DEBUG_HOOKS: "1", + SUPERSET_HOOK_DEBUG_LOG: debugLogPath, + SUPERSET_TAB_ID: "tab-1", + }, + encoding: "utf-8", + }); + + const notifications = readFileSync(notifyCapturePath, "utf-8"); + expect(notifications).toContain('{"hook_event_name":"Start"}'); + expect(notifications).not.toContain('{"hook_event_name":"Stop"}'); + + const debugLog = readFileSync(debugLogPath, "utf-8"); + expect(debugLog).toContain("watching session="); + expect(debugLog).toContain("emitting Start"); + expect(debugLog).toContain("tabId=tab-1"); + }); + + it("does not emit codex events from unrelated rollout files", () => { + const realBinDir = path.join(TEST_ROOT, "real-bin"); + const realCodex = path.join(realBinDir, "codex"); + const wrapperPath = path.join(TEST_BIN_DIR, "codex"); + const notifyPath = path.join(TEST_HOOKS_DIR, "notify.sh"); + const notifyCapturePath = path.join( + TEST_ROOT, + "codex-rollout-notify-events.txt", + ); + const debugLogPath = path.join(TEST_ROOT, "codex-rollout-debug.log"); + const codexHome = path.join(TEST_ROOT, "custom-codex-home"); + + mkdirSync(realBinDir, { recursive: true }); + mkdirSync(TEST_HOOKS_DIR, { recursive: true }); + writeFileSync( + notifyPath, + `#!/bin/bash +printf '%s\n' "$1" >> "$NOTIFY_CAPTURE_PATH" +exit 0 +`, + { mode: 0o755 }, + ); + chmodSync(notifyPath, 0o755); + writeFileSync( + realCodex, + `#!/bin/bash +set -eu +rollout_dir="$CODEX_HOME/sessions/2026/05/09" +mkdir -p "$rollout_dir" +: > "$CODEX_TUI_SESSION_LOG_PATH" +printf '{"type":"event_msg","payload":{"type":"task_started"}}\n' >> "$rollout_dir/rollout-other.jsonl" +sleep 0.3 +exit 0 +`, + { mode: 0o755 }, + ); + chmodSync(realCodex, 0o755); + + createCodexWrapper(); + + execFileSync(wrapperPath, [], { + env: { + ...process.env, + CODEX_HOME: codexHome, + NOTIFY_CAPTURE_PATH: notifyCapturePath, + PATH: `${TEST_BIN_DIR}:${realBinDir}:${process.env.PATH || ""}`, + SUPERSET_DEBUG_HOOKS: "1", + SUPERSET_HOOK_DEBUG_LOG: debugLogPath, + SUPERSET_TERMINAL_ID: "terminal-1", + }, + encoding: "utf-8", + }); + + expect(existsSync(notifyCapturePath)).toBe(false); + expect(readFileSync(debugLogPath, "utf-8")).toContain("watching session="); + }); + it("creates mastracode wrapper passthrough", () => { createMastraWrapper(); @@ -273,9 +449,42 @@ exit 0 expect(wrapper).toContain("# Superset wrapper for amp"); expect(wrapper).toContain('REAL_BIN="$(find_real_binary "amp")"'); + expect(wrapper).toContain('export SUPERSET_AGENT_ID="amp"'); expect(wrapper).toContain('exec "$REAL_BIN" "$@"'); }); + it("creates Amp lifecycle plugin", () => { + createAmpPlugin(); + + const pluginPath = getAmpGlobalPluginPath(); + const plugin = readFileSync(pluginPath, "utf-8"); + + expect(pluginPath).toBe( + path.join( + mockedHomeDir, + ".config", + "amp", + "plugins", + "superset-lifecycle.ts", + ), + ); + expect(plugin).toBe(getAmpPluginContent()); + expect(plugin).toContain(AMP_PLUGIN_MARKER); + expect(plugin).toContain( + "// @i-know-the-amp-plugin-api-is-wip-and-very-experimental-right-now", + ); + expect(plugin).toContain('amp.on("session.start"'); + expect(plugin).toContain('notify("SessionStart", event)'); + expect(plugin).toContain('amp.on("agent.start"'); + expect(plugin).toContain('notify("Start", event)'); + expect(plugin).toContain('amp.on("agent.end"'); + expect(plugin).toContain('notify("Stop", event)'); + expect(plugin).toContain('import { spawn } from "node:child_process"'); + expect(plugin).toContain('SUPERSET_AGENT_ID: "amp"'); + expect(plugin).toContain("[superset-amp-plugin]"); + expect(plugin).toContain("SUPERSET_HOME_DIR"); + }); + it("creates droid wrapper passthrough", () => { createDroidWrapper(); @@ -284,12 +493,14 @@ exit 0 expect(wrapper).toContain("# Superset wrapper for droid"); expect(wrapper).toContain('REAL_BIN="$(find_real_binary "droid")"'); + expect(wrapper).toContain('export SUPERSET_AGENT_ID="droid"'); expect(wrapper).toContain('exec "$REAL_BIN" "$@"'); }); it("replaces stale Cursor hook commands from old superset paths", () => { const cursorHooksPath = path.join(mockedHomeDir, ".cursor", "hooks.json"); - const staleHookPath = "/tmp/.superset-old/hooks/cursor-hook.sh"; + const staleHookPath = + "/tmp/worktree/superset-dev-data/hooks/cursor-hook.sh"; const currentHookPath = "/tmp/.superset-new/hooks/cursor-hook.sh"; mkdirSync(path.dirname(cursorHooksPath), { recursive: true }); @@ -333,6 +544,16 @@ exit 0 ), ).toBe(true); expect(Array.isArray(parsed.hooks.stop)).toBe(true); + expect( + parsed.hooks.sessionStart.some( + (entry) => entry.command === `${currentHookPath} SessionStart`, + ), + ).toBe(true); + expect( + parsed.hooks.sessionEnd.some( + (entry) => entry.command === `${currentHookPath} SessionEnd`, + ), + ).toBe(true); expect(Array.isArray(parsed.hooks.beforeShellExecution)).toBe(true); expect(Array.isArray(parsed.hooks.beforeMCPExecution)).toBe(true); expect(JSON.parse(content2)).toEqual(JSON.parse(content)); @@ -344,7 +565,8 @@ exit 0 ".gemini", "settings.json", ); - const staleHookPath = "/tmp/.superset-old/hooks/gemini-hook.sh"; + const staleHookPath = + "/tmp/worktree/superset-dev-data/hooks/gemini-hook.sh"; const currentHookPath = "/tmp/.superset-new/hooks/gemini-hook.sh"; mkdirSync(path.dirname(geminiSettingsPath), { recursive: true }); @@ -354,6 +576,9 @@ exit 0 { hooks: { BeforeAgent: [ + { + command: staleHookPath, + }, { hooks: [{ type: "command", command: staleHookPath }], }, @@ -385,13 +610,19 @@ exit 0 const parsed = JSON.parse(content) as { hooks: Record< string, - Array<{ hooks: Array<{ type: string; command: string }> }> + Array<{ + command?: string; + hooks?: Array<{ type: string; command: string }>; + }> >; }; const parsed2 = JSON.parse(content2) as { hooks: Record< string, - Array<{ hooks: Array<{ type: string; command: string }> }> + Array<{ + command?: string; + hooks?: Array<{ type: string; command: string }>; + }> >; }; @@ -408,8 +639,10 @@ exit 0 ), ).toBe(true); expect( - hooks.some((def) => - def.hooks.some((hook) => hook.command.includes(staleHookPath)), + hooks.some( + (def) => + def.command?.includes(staleHookPath) || + def.hooks?.some((hook) => hook.command.includes(staleHookPath)), ), ).toBe(false); } @@ -417,7 +650,7 @@ exit 0 const beforeAgent = parsed.hooks.BeforeAgent; expect( beforeAgent.some((def) => - def.hooks.some((hook) => hook.command === "/opt/custom-hook.sh"), + def.hooks?.some((hook) => hook.command === "/opt/custom-hook.sh"), ), ).toBe(true); @@ -432,19 +665,26 @@ exit 0 ), ).toBe(true); expect( - hooks.some((def) => - def.hooks.some((hook) => hook.command.includes(staleHookPath)), + hooks.some( + (def) => + def.command?.includes(staleHookPath) || + def.hooks?.some((hook) => hook.command.includes(staleHookPath)), ), ).toBe(false); } expect( parsed2.hooks.BeforeAgent.some((def) => - def.hooks.some((hook) => hook.command === "/opt/custom-hook.sh"), + def.hooks?.some((hook) => hook.command === "/opt/custom-hook.sh"), ), ).toBe(true); expect(JSON.parse(content2)).toEqual(JSON.parse(content)); }); + it("bumps Cursor and Gemini hook script markers when hook semantics change", () => { + expect(CURSOR_HOOK_MARKER).toBe("# Superset cursor hook v2"); + expect(GEMINI_HOOK_MARKER).toBe("# Superset gemini hook v2"); + }); + it("replaces stale Mastra hook commands from old superset paths", () => { const mastraHooksPath = path.join( mockedHomeDir, @@ -481,7 +721,13 @@ exit 0 string, Array<{ type: string; command: string }> >; - const managedEvents = ["UserPromptSubmit", "Stop", "PostToolUse"] as const; + const managedEvents = [ + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "Stop", + "PostToolUse", + ] as const; for (const eventName of managedEvents) { const hooks = parsed[eventName]; @@ -490,7 +736,8 @@ exit 0 hooks.some( (entry) => entry.type === "command" && - entry.command === `bash '${currentHookPath}'`, + entry.command === + `SUPERSET_AGENT_ID=mastracode bash '${currentHookPath}'`, ), ).toBe(true); expect(hooks.some((entry) => entry.command.includes(staleHookPath))).toBe( @@ -587,7 +834,10 @@ exit 0 expect(Array.isArray(hooks)).toBe(true); expect( hooks.some((def) => - def.hooks.some((hook) => hook.command === currentHookPath), + def.hooks.some( + (hook) => + hook.command === `SUPERSET_AGENT_ID=droid '${currentHookPath}'`, + ), ), ).toBe(true); expect( @@ -908,6 +1158,7 @@ describe("agent-wrappers codex hooks.json", () => { >; }; + const expectedCommand = `SUPERSET_AGENT_ID=codex "${notifyPath}"`; for (const eventName of [ "SessionStart", "UserPromptSubmit", @@ -917,7 +1168,7 @@ describe("agent-wrappers codex hooks.json", () => { expect(Array.isArray(hooks)).toBe(true); expect( hooks.some((def) => - def.hooks.some((hook) => hook.command === notifyPath), + def.hooks.some((hook) => hook.command === expectedCommand), ), ).toBe(true); } @@ -1022,13 +1273,15 @@ describe("agent-wrappers codex hooks.json", () => { ), ).toBe(true); + const expectedManagedCommand = `SUPERSET_AGENT_ID=codex "${notifyPath}"`; // Adds managed hooks for SessionStart, UserPromptSubmit, Stop for (const eventName of ["SessionStart", "UserPromptSubmit", "Stop"]) { expect( parsed.hooks[eventName].some( (def: { hooks: Array<{ command: string }> }) => def.hooks.some( - (hook: { command: string }) => hook.command === notifyPath, + (hook: { command: string }) => + hook.command === expectedManagedCommand, ), ), ).toBe(true); @@ -1039,7 +1292,8 @@ describe("agent-wrappers codex hooks.json", () => { parsed.hooks.PreToolUse.some( (def: { hooks: Array<{ command: string }> }) => def.hooks.some( - (hook: { command: string }) => hook.command === notifyPath, + (hook: { command: string }) => + hook.command === expectedManagedCommand, ), ), ).toBe(false); @@ -1047,7 +1301,8 @@ describe("agent-wrappers codex hooks.json", () => { parsed.hooks.PostToolUse.some( (def: { hooks: Array<{ command: string }> }) => def.hooks.some( - (hook: { command: string }) => hook.command === notifyPath, + (hook: { command: string }) => + hook.command === expectedManagedCommand, ), ), ).toBe(false); @@ -1102,6 +1357,7 @@ describe("agent-wrappers codex hooks.json", () => { >; }; + const expectedManagedCommand = `SUPERSET_AGENT_ID=codex "${currentHookPath}"`; for (const eventName of [ "SessionStart", "UserPromptSubmit", @@ -1111,7 +1367,7 @@ describe("agent-wrappers codex hooks.json", () => { expect(Array.isArray(hooks)).toBe(true); expect( hooks.some((def) => - def.hooks.some((hook) => hook.command === currentHookPath), + def.hooks.some((hook) => hook.command === expectedManagedCommand), ), ).toBe(true); expect( @@ -1177,6 +1433,7 @@ describe("agent-wrappers codex hooks.json", () => { >; }; + const expectedManagedCommand = `SUPERSET_AGENT_ID=codex "${currentHookPath}"`; expect(parsed.hooks.UserPromptSubmit).toBeDefined(); expect( parsed.hooks.UserPromptSubmit?.some((def) => @@ -1192,7 +1449,7 @@ describe("agent-wrappers codex hooks.json", () => { ).toBe(false); expect( parsed.hooks.UserPromptSubmit?.some((def) => - def.hooks.some((hook) => hook.command === currentHookPath), + def.hooks.some((hook) => hook.command === expectedManagedCommand), ), ).toBe(true); }); @@ -1240,6 +1497,7 @@ describe("agent-wrappers codex hooks.json", () => { >; }; + const expectedManagedCommand = `SUPERSET_AGENT_ID=codex "${currentHookPath}"`; for (const eventName of [ "SessionStart", "UserPromptSubmit", @@ -1249,7 +1507,7 @@ describe("agent-wrappers codex hooks.json", () => { expect(Array.isArray(hooks)).toBe(true); expect( hooks.some((def) => - def.hooks.some((hook) => hook.command === currentHookPath), + def.hooks.some((hook) => hook.command === expectedManagedCommand), ), ).toBe(true); expect( 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 2f51854eac7..cb55cba156e 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -1,4 +1,11 @@ -export { createAmpWrapper } from "./agent-wrappers-amp"; +export { + AMP_PLUGIN_FILE, + AMP_PLUGIN_MARKER, + createAmpPlugin, + createAmpWrapper, + getAmpGlobalPluginPath, + getAmpPluginContent, +} from "./agent-wrappers-amp"; export { buildCodexWrapperExecLine, cleanupGlobalOpenCodePlugin, diff --git a/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts b/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts index 4a3a2409719..12c001e6295 100644 --- a/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts +++ b/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts @@ -5,6 +5,7 @@ export type SupersetManagedBinary = AgentType | "droid"; export const DESKTOP_AGENT_SETUP_ACTIONS = [ "notify-script", "cleanup-global-opencode-plugin", + "amp-plugin", "amp-wrapper", "claude-settings-json", "claude-wrapper", @@ -44,7 +45,7 @@ export const DESKTOP_AGENT_SETUP_BOOTSTRAP_ACTIONS = [ export const DESKTOP_AGENT_SETUP_TARGETS = [ { id: "amp", - setupActions: ["amp-wrapper"], + setupActions: ["amp-plugin", "amp-wrapper"], managedBinary: true, }, { diff --git a/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts b/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts index effaacd0d8a..362a95b40ba 100644 --- a/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts +++ b/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts @@ -1,5 +1,6 @@ import { cleanupGlobalOpenCodePlugin, + createAmpPlugin, createAmpWrapper, createClaudeSettingsJson, createClaudeWrapper, @@ -32,6 +33,7 @@ const DESKTOP_AGENT_SETUP_RUNNERS: Record void> = { "notify-script": createNotifyScript, "cleanup-global-opencode-plugin": cleanupGlobalOpenCodePlugin, + "amp-plugin": createAmpPlugin, "amp-wrapper": createAmpWrapper, "claude-settings-json": createClaudeSettingsJson, "claude-wrapper": createClaudeWrapper, diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts index ed81ec70bbb..01e09c02ab6 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts @@ -3,29 +3,24 @@ import { readFileSync } from "node:fs"; import path from "node:path"; describe("getNotifyScriptContent", () => { - it("keeps v1 fallback session ids out of the v2 host-service payload", () => { + it("emits the v2 host-service payload with full agent identity", () => { const script = readFileSync( path.join(import.meta.dir, "templates", "notify-hook.template.sh"), "utf-8", ); - expect(script).toContain('RESOURCE_ID=$(echo "$INPUT"'); + expect(script).toContain('HOOK_SESSION_ID=$(echo "$INPUT"'); expect(script).toContain( - "SESSION_ID=" + "\u0024{RESOURCE_ID:-$HOOK_SESSION_ID}", + 'PAYLOAD="{\\"json\\":{\\"terminalId\\":\\"$(json_escape "$SUPERSET_TERMINAL_ID")\\",\\"eventType\\":\\"$(json_escape "$EVENT_TYPE")\\",\\"agent\\":{\\"agentId\\":\\"$(json_escape "$SUPERSET_AGENT_ID")\\",\\"sessionId\\":\\"$(json_escape "$SESSION_ID")\\"}}}"', ); expect(script).toContain( - 'PAYLOAD="{\\"json\\":{\\"terminalId\\":\\"$(json_escape "$SUPERSET_TERMINAL_ID")\\",\\"eventType\\":\\"$(json_escape "$EVENT_TYPE")\\"}}"', - ); - expect(script).toContain('--data-urlencode "resourceId=$RESOURCE_ID"'); - expect(script).toContain( - '--data-urlencode "hookSessionId=$HOOK_SESSION_ID"', - ); - expect(script).toContain( - "event=$EVENT_TYPE terminalId=$SUPERSET_TERMINAL_ID sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID", + "event=$EVENT_TYPE terminalId=$SUPERSET_TERMINAL_ID agentId=$SUPERSET_AGENT_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID paneId=$SUPERSET_PANE_ID tabId=$SUPERSET_TAB_ID workspaceId=$SUPERSET_WORKSPACE_ID", ); + expect(script).toContain('V1_EVENT_TYPE="$EVENT_TYPE"'); + expect(script).toContain('V1_EVENT_TYPE="Stop"'); }); - it("gives the v2 host-service hook enough time to avoid false fallback", () => { + it("gives the v2 host-service hook enough time to deliver", () => { const script = readFileSync( path.join(import.meta.dir, "templates", "notify-hook.template.sh"), "utf-8", @@ -36,24 +31,48 @@ describe("getNotifyScriptContent", () => { ); }); - it("keeps the legacy v1 fallback path when no host-service hook URL exists", () => { + it("falls back to the v1 Electron hook when v2 is unavailable", () => { const script = readFileSync( path.join(import.meta.dir, "templates", "notify-hook.template.sh"), "utf-8", ); - expect(script).toContain('if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then'); expect(script).toContain( - '[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && exit 0', + 'if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_TERMINAL_ID" ]; then', ); expect(script).toContain( - 'curl -sG "http://127.0.0.1:' + - "$" + - "{SUPERSET_PORT:-{{DEFAULT_PORT}}}" + - '/hook/complete"', - ); - expect(script).toContain('--data-urlencode "paneId=$SUPERSET_PANE_ID"'); - expect(script).toContain('--data-urlencode "tabId=$SUPERSET_TAB_ID"'); - expect(script).toContain('--data-urlencode "sessionId=$SESSION_ID"'); + '[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && exit 0', + ); + expect(script).toContain("/hook/complete"); + expect(script).toContain("SUPERSET_TAB_ID"); + expect(script).toContain("SUPERSET_PANE_ID"); }); }); + +describe("per-agent hook scripts dispatch to v2", () => { + const expectedV2Payload = + 'PAYLOAD="{\\"json\\":{\\"terminalId\\":\\"$(json_escape "$SUPERSET_TERMINAL_ID")\\",\\"eventType\\":\\"$(json_escape "$EVENT_TYPE")\\",\\"agent\\":{\\"agentId\\":\\"$(json_escape "$SUPERSET_AGENT_ID")\\",\\"sessionId\\":\\"$(json_escape "$HOOK_SESSION_ID")\\"}}}"'; + + for (const template of [ + "cursor-hook.template.sh", + "copilot-hook.template.sh", + "gemini-hook.template.sh", + ]) { + it(`${template} posts v2 first and falls back to v1`, () => { + const script = readFileSync( + path.join(import.meta.dir, "templates", template), + "utf-8", + ); + expect(script).toContain(expectedV2Payload); + expect(script).toContain('curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL"'); + expect(script).toContain( + 'if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_TERMINAL_ID" ]; then', + ); + expect(script).toContain("/hook/complete"); + expect(script).toContain('V1_EVENT_TYPE="$EVENT_TYPE"'); + expect(script).toContain("eventType=$V1_EVENT_TYPE"); + expect(script).toContain("SUPERSET_TAB_ID"); + expect(script).toContain("SUPERSET_PANE_ID"); + }); + } +}); diff --git a/apps/desktop/src/main/lib/agent-setup/templates/amp-plugin.template.ts b/apps/desktop/src/main/lib/agent-setup/templates/amp-plugin.template.ts new file mode 100644 index 00000000000..5213f8202ef --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/templates/amp-plugin.template.ts @@ -0,0 +1,149 @@ +{{MARKER}} +// @i-know-the-amp-plugin-api-is-wip-and-very-experimental-right-now + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +declare const process: { + env?: Record; + stderr?: { write: (message: string) => void }; +}; + +type AmpEventName = "session.start" | "agent.start" | "agent.end"; +type AmpApi = { + on: ( + eventName: AmpEventName, + handler: (event?: unknown) => unknown | Promise, + ) => void; +}; +type SupersetGlobal = typeof globalThis & { + __supersetAmpLifecyclePluginV1?: boolean; +}; + +function getStringProperty( + value: Record, + keys: string[], +): string | undefined { + for (const key of keys) { + const property = value[key]; + if (typeof property === "string" && property.length > 0) { + return property; + } + } +} + +function getSessionId(event: unknown): string | undefined { + if (!event || typeof event !== "object") return undefined; + const record = event as Record; + const direct = getStringProperty(record, [ + "threadID", + "threadId", + "sessionID", + "sessionId", + "id", + ]); + if (direct) return direct; + + const thread = record.thread; + if (thread && typeof thread === "object") { + return getStringProperty(thread as Record, [ + "id", + "threadID", + "threadId", + ]); + } +} + +function isDebugEnabled(): boolean { + const env = typeof process === "undefined" ? {} : process.env ?? {}; + return ["SUPERSET_DEBUG_HOOKS", "SUPERSET_DEBUG"].some((key) => { + const value = env[key]; + return value === "1" || value === "true" || value === "TRUE"; + }); +} + +function debugLog(message: string): void { + if (!isDebugEnabled()) return; + process?.stderr?.write?.("[superset-amp-plugin] " + message + "\n"); +} + +export default function supersetAmpLifecyclePlugin(amp: AmpApi) { + const supersetGlobal = globalThis as SupersetGlobal; + if (supersetGlobal.__supersetAmpLifecyclePluginV1) return; + supersetGlobal.__supersetAmpLifecyclePluginV1 = true; + + const env = typeof process === "undefined" ? {} : process.env ?? {}; + if (!env.SUPERSET_TERMINAL_ID && !env.SUPERSET_TAB_ID) { + debugLog("disabled: missing Superset terminal env"); + return; + } + + const supersetHome = env.SUPERSET_HOME_DIR || join(homedir(), ".superset"); + const notifyPath = join(supersetHome, "hooks", "notify.sh"); + if (!existsSync(notifyPath)) { + debugLog("disabled: notify hook missing at " + notifyPath); + return; + } + + debugLog( + "enabled terminalId=" + + (env.SUPERSET_TERMINAL_ID || "") + + " tabId=" + + (env.SUPERSET_TAB_ID || "") + + " notify=" + + notifyPath, + ); + + const notify = (hookEventName: string, event?: unknown) => { + const sessionId = getSessionId(event); + const payload = JSON.stringify({ + hook_event_name: hookEventName, + resourceId: sessionId, + session_id: sessionId, + }); + + try { + const child = spawn(notifyPath, [], { + stdio: ["pipe", "ignore", "ignore"], + detached: true, + env: { ...env, SUPERSET_AGENT_ID: "amp" }, + }); + child.on("error", (error) => { + debugLog("spawn failed event=" + hookEventName + " error=" + error.message); + }); + child.stdin?.on("error", (error) => { + debugLog("stdin failed event=" + hookEventName + " error=" + error.message); + }); + child.stdin?.end(payload); + child.unref(); + debugLog( + "spawned event=" + + hookEventName + + " sessionId=" + + (sessionId || "") + + " terminalId=" + + (env.SUPERSET_TERMINAL_ID || ""), + ); + } catch (error) { + // Best effort only. Superset notifications must never interrupt Amp. + debugLog( + "spawn threw event=" + + hookEventName + + " error=" + + (error instanceof Error ? error.message : String(error)), + ); + } + }; + + amp.on("session.start", async (event) => { + notify("SessionStart", event); + }); + amp.on("agent.start", async (event) => { + notify("Start", event); + }); + amp.on("agent.end", async (event) => { + notify("Stop", event); + }); +} diff --git a/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.sh index 8aa12395105..75a5f6cdd85 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.sh @@ -1,83 +1,75 @@ -# Codex exposes completion notifications via notify. -# For per-prompt Start notifications and permission requests, watch the TUI -# session log for task_started/exec_command_begin and *_approval_request events. -if [ -n "$SUPERSET_TAB_ID" ] && [ -f "{{NOTIFY_PATH}}" ]; then - export CODEX_TUI_RECORD_SESSION=1 - if [ -z "$CODEX_TUI_SESSION_LOG_PATH" ]; then - _superset_codex_ts="$(date +%s 2>/dev/null || echo "$$")" - export CODEX_TUI_SESSION_LOG_PATH="${TMPDIR:-/tmp}/superset-codex-session-$$_${_superset_codex_ts}.jsonl" - fi +# Codex's native notify callback only reports completion, so the wrapper uses +# Codex's process-scoped TUI session log for Start/permission events. Avoid +# tailing global rollout files: concurrent Codex sessions share that directory. +_superset_debug_enabled="0" +case "$SUPERSET_DEBUG_HOOKS" in + 1|true|TRUE|True|yes|YES|on|ON) _superset_debug_enabled="1" ;; +esac +if [ "$_superset_debug_enabled" != "1" ] && { [ "$SUPERSET_ENV" = "development" ] || [ "$NODE_ENV" = "development" ]; }; then + _superset_debug_enabled="1" +fi + +_superset_notify_path="{{NOTIFY_PATH}}" +_superset_debug_log="${SUPERSET_HOOK_DEBUG_LOG:-/tmp/superset-codex-hooks.log}" +_superset_has_superset_context="0" +[ -n "$SUPERSET_TERMINAL_ID$SUPERSET_TAB_ID$SUPERSET_PANE_ID" ] && _superset_has_superset_context="1" + +_superset_debug() { + [ "$_superset_debug_enabled" = "1" ] || return 0 + printf '%s [codex-wrapper] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date)" "$*" >> "$_superset_debug_log" 2>/dev/null || true +} + +if [ "$_superset_has_superset_context" = "1" ] && [ -f "$_superset_notify_path" ]; then + export CODEX_TUI_RECORD_SESSION="${CODEX_TUI_RECORD_SESSION:-1}" + export CODEX_TUI_SESSION_LOG_PATH="${CODEX_TUI_SESSION_LOG_PATH:-${TMPDIR:-/tmp}/superset-codex-session-$$_$(date +%s).jsonl}" + _superset_debug "session watcher starting terminalId=$SUPERSET_TERMINAL_ID tabId=$SUPERSET_TAB_ID paneId=$SUPERSET_PANE_ID log=$CODEX_TUI_SESSION_LOG_PATH notify=$_superset_notify_path" ( - _superset_log="$CODEX_TUI_SESSION_LOG_PATH" - _superset_notify="{{NOTIFY_PATH}}" - _superset_last_turn_id="" - _superset_last_approval_id="" - _superset_last_exec_call_id="" - _superset_approval_fallback_seq=0 + _superset_notify="$_superset_notify_path" + _superset_session_log="$CODEX_TUI_SESSION_LOG_PATH" _superset_emit_event() { - _superset_event="$1" - _superset_payload=$(printf '{"hook_event_name":"%s"}' "$_superset_event") + _superset_payload=$(printf '{"hook_event_name":"%s"}' "$1") + _superset_debug "emitting $1 via $_superset_notify" bash "$_superset_notify" "$_superset_payload" >/dev/null 2>&1 || true } - # Wait briefly for codex to create the session log. _superset_i=0 - while [ ! -f "$_superset_log" ] && [ "$_superset_i" -lt 200 ]; do + while [ ! -f "$_superset_session_log" ] && [ "$_superset_i" -lt 200 ]; do _superset_i=$((_superset_i + 1)) - sleep 0.05 + sleep 0.1 done - if [ ! -f "$_superset_log" ]; then + if [ ! -f "$_superset_session_log" ]; then + _superset_debug "session log not found path=$_superset_session_log" exit 0 fi + _superset_debug "watching session=$_superset_session_log" - tail -n 0 -F "$_superset_log" 2>/dev/null | while IFS= read -r _superset_line; do + tail -n +1 -F "$_superset_session_log" 2>/dev/null | while IFS= read -r _superset_line; do case "$_superset_line" in - *'"dir":"to_tui"'*'"kind":"codex_event"'*'"msg":{"type":"task_started"'*) - _superset_turn_id=$(printf '%s\n' "$_superset_line" | awk -F'"turn_id":"' 'NF > 1 { sub(/".*/, "", $2); print $2; exit }') - [ -n "$_superset_turn_id" ] || _superset_turn_id="task_started" - if [ "$_superset_turn_id" != "$_superset_last_turn_id" ]; then - _superset_last_turn_id="$_superset_turn_id" - _superset_emit_event "Start" - fi - ;; - *'"dir":"to_tui"'*'"kind":"codex_event"'*'"msg":{"type":"'*'_approval_request"'*) - _superset_approval_id=$(printf '%s\n' "$_superset_line" | awk -F'"id":"' 'NF > 1 { sub(/".*/, "", $2); print $2; exit }') - [ -n "$_superset_approval_id" ] || _superset_approval_id=$(printf '%s\n' "$_superset_line" | awk -F'"approval_id":"' 'NF > 1 { sub(/".*/, "", $2); print $2; exit }') - [ -n "$_superset_approval_id" ] || _superset_approval_id=$(printf '%s\n' "$_superset_line" | awk -F'"call_id":"' 'NF > 1 { sub(/".*/, "", $2); print $2; exit }') - if [ -z "$_superset_approval_id" ]; then - _superset_approval_fallback_seq=$((_superset_approval_fallback_seq + 1)) - _superset_approval_id="approval_request_${_superset_approval_fallback_seq}" - fi - if [ "$_superset_approval_id" != "$_superset_last_approval_id" ]; then - _superset_last_approval_id="$_superset_approval_id" - _superset_emit_event "PermissionRequest" - fi - ;; - *'"dir":"to_tui"'*'"kind":"codex_event"'*'"msg":{"type":"exec_command_begin"'*) - _superset_exec_call_id=$(printf '%s\n' "$_superset_line" | awk -F'"call_id":"' 'NF > 1 { sub(/".*/, "", $2); print $2; exit }') - if [ -n "$_superset_exec_call_id" ]; then - if [ "$_superset_exec_call_id" != "$_superset_last_exec_call_id" ]; then - _superset_last_exec_call_id="$_superset_exec_call_id" - _superset_emit_event "Start" - fi - else - _superset_emit_event "Start" - fi - ;; + *'"dir":"from_tui"'*'"kind":"op"'*'"UserTurn"'*) _superset_emit_event "Start" ;; + *'_approval_request"'*) _superset_emit_event "PermissionRequest" ;; esac done ) & - SUPERSET_CODEX_START_WATCHER_PID=$! + SUPERSET_CODEX_SESSION_WATCHER_PID=$! + _superset_debug "session watcher pid=$SUPERSET_CODEX_SESSION_WATCHER_PID" +else + _superset_notify_exists="0" + [ -f "$_superset_notify_path" ] && _superset_notify_exists="1" + _superset_debug "session watcher disabled hasSupersetContext=$_superset_has_superset_context terminalId=$SUPERSET_TERMINAL_ID tabId=$SUPERSET_TAB_ID paneId=$SUPERSET_PANE_ID notifyExists=$_superset_notify_exists notify=$_superset_notify_path" fi -"$REAL_BIN" --enable codex_hooks -c 'notify=["bash","{{NOTIFY_PATH}}"]' "$@" +# `hooks` (formerly `codex_hooks`) is stable and default-enabled in codex +# >=0.129; the legacy `notify=...` callback remains the completion source. +"$REAL_BIN" --enable hooks -c 'notify=["bash","{{NOTIFY_PATH}}"]' "$@" SUPERSET_CODEX_STATUS=$? +_superset_debug "codex exited status=$SUPERSET_CODEX_STATUS" -if [ -n "$SUPERSET_CODEX_START_WATCHER_PID" ]; then - kill "$SUPERSET_CODEX_START_WATCHER_PID" >/dev/null 2>&1 || true - wait "$SUPERSET_CODEX_START_WATCHER_PID" 2>/dev/null || true +if [ -n "$SUPERSET_CODEX_SESSION_WATCHER_PID" ]; then + kill "$SUPERSET_CODEX_SESSION_WATCHER_PID" >/dev/null 2>&1 || true + wait "$SUPERSET_CODEX_SESSION_WATCHER_PID" 2>/dev/null || true + _superset_debug "session watcher stopped pid=$SUPERSET_CODEX_SESSION_WATCHER_PID" fi exit "$SUPERSET_CODEX_STATUS" diff --git a/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh index cdfb1581084..768a837e072 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/copilot-hook.template.sh @@ -1,20 +1,16 @@ #!/bin/bash {{MARKER}} -# Called by GitHub Copilot CLI hooks to notify Superset of agent lifecycle events -# Events: sessionStart → Start, sessionEnd → Stop, userPromptSubmitted → Start, -# postToolUse → Start, preToolUse → PermissionRequest -# Copilot CLI hooks receive JSON via stdin and MUST output valid JSON to stdout +# GitHub Copilot CLI lifecycle hook. JSON in via stdin; MUST print valid +# JSON to stdout before exit so copilot doesn't block on the hook. -# Drain stdin — Copilot pipes JSON context that we don't need, but we must -# consume it to prevent broken-pipe errors from blocking the agent -cat > /dev/null 2>&1 +INPUT=$(cat) +HOOK_SESSION_ID=$(printf '%s' "$INPUT" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') -# Event name is passed as $1 from our hooks.json bash command EVENT_TYPE="$1" case "$EVENT_TYPE" in - sessionStart) EVENT_TYPE="Start" ;; - sessionEnd) EVENT_TYPE="Stop" ;; + sessionStart) EVENT_TYPE="SessionStart" ;; + sessionEnd) EVENT_TYPE="SessionEnd" ;; userPromptSubmitted) EVENT_TYPE="Start" ;; postToolUse) EVENT_TYPE="Start" ;; preToolUse) EVENT_TYPE="PermissionRequest" ;; @@ -24,9 +20,32 @@ case "$EVENT_TYPE" in ;; esac -# Must output valid JSON to avoid blocking the agent printf '{}\n' +V1_EVENT_TYPE="$EVENT_TYPE" +case "$V1_EVENT_TYPE" in + SessionStart) V1_EVENT_TYPE="Start" ;; + SessionEnd) V1_EVENT_TYPE="Stop" ;; +esac + +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_TERMINAL_ID" ]; then + PAYLOAD="{\"json\":{\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\",\"agent\":{\"agentId\":\"$(json_escape "$SUPERSET_AGENT_ID")\",\"sessionId\":\"$(json_escape "$HOOK_SESSION_ID")\"}}}" + + STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ + --connect-timeout 2 --max-time 5 \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + -o /dev/null -w "%{http_code}" 2>/dev/null) + + case "$STATUS_CODE" in + 2*) exit 0 ;; + esac +fi + [ -z "$SUPERSET_TAB_ID" ] && exit 0 curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ @@ -34,7 +53,9 @@ curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ --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 "sessionId=$HOOK_SESSION_ID" \ + --data-urlencode "hookSessionId=$HOOK_SESSION_ID" \ + --data-urlencode "eventType=$V1_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/agent-setup/templates/cursor-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh index f2e2483ffa9..cbfbbae638d 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/cursor-hook.template.sh @@ -1,30 +1,49 @@ #!/bin/bash {{MARKER}} -# Called by cursor-agent hooks to notify Superset of agent lifecycle events -# Events: Start (beforeSubmitPrompt), Stop (stop), -# PermissionRequest (beforeShellExecution, beforeMCPExecution) +# cursor-agent lifecycle hook. Event name comes via argv from hooks.json. -# Drain stdin — Cursor pipes JSON context that we don't need, but we must consume it -# to prevent broken-pipe errors from blocking the agent -cat > /dev/null 2>&1 +INPUT=$(cat) +HOOK_SESSION_ID=$(printf '%s' "$INPUT" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') EVENT_TYPE="$1" -# Map event type and determine if we need to respond with JSON NEEDS_RESPONSE=false case "$EVENT_TYPE" in - Start|Stop) ;; + Start|Stop|SessionStart|SessionEnd) ;; PermissionRequest) NEEDS_RESPONSE=true ;; *) exit 0 ;; esac -# For permission hooks, auto-approve by writing JSON to stdout -# This must happen before any exit to avoid blocking the agent +# Permission hooks auto-approve via JSON on stdout. Must print before any +# exit path so cursor-agent isn't left blocked. if [ "$NEEDS_RESPONSE" = "true" ]; then printf '{"continue":true}\n' fi -# cursor-agent runs inside a Superset terminal, so env vars are inherited directly +V1_EVENT_TYPE="$EVENT_TYPE" +case "$V1_EVENT_TYPE" in + SessionStart) V1_EVENT_TYPE="Start" ;; + SessionEnd) V1_EVENT_TYPE="Stop" ;; +esac + +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_TERMINAL_ID" ]; then + PAYLOAD="{\"json\":{\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\",\"agent\":{\"agentId\":\"$(json_escape "$SUPERSET_AGENT_ID")\",\"sessionId\":\"$(json_escape "$HOOK_SESSION_ID")\"}}}" + + STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ + --connect-timeout 2 --max-time 5 \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + -o /dev/null -w "%{http_code}" 2>/dev/null) + + case "$STATUS_CODE" in + 2*) exit 0 ;; + esac +fi + [ -z "$SUPERSET_TAB_ID" ] && exit 0 curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ @@ -32,7 +51,9 @@ curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ --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 "sessionId=$HOOK_SESSION_ID" \ + --data-urlencode "hookSessionId=$HOOK_SESSION_ID" \ + --data-urlencode "eventType=$V1_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/agent-setup/templates/gemini-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh index a54e780c99a..4277737d1c8 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/gemini-hook.template.sh @@ -1,31 +1,51 @@ #!/bin/bash {{MARKER}} -# Called by Gemini CLI hooks to notify Superset of agent lifecycle events -# Events: BeforeAgent → Start, AfterAgent → Stop, AfterTool → Start -# Gemini hooks receive JSON via stdin and MUST output valid JSON to stdout +# Gemini CLI lifecycle hook. JSON in via stdin; MUST print valid JSON to +# stdout before exit so gemini doesn't block on the hook. -# Read JSON from stdin INPUT=$(cat) -# Extract hook_event_name from Gemini's JSON payload -EVENT_TYPE=$(echo "$INPUT" | grep -oE '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') +EVENT_TYPE=$(printf '%s' "$INPUT" | grep -oE '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') +HOOK_SESSION_ID=$(printf '%s' "$INPUT" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') -# Map Gemini event names to Superset event types case "$EVENT_TYPE" in - BeforeAgent) EVENT_TYPE="Start" ;; - AfterAgent) EVENT_TYPE="Stop" ;; - AfterTool) EVENT_TYPE="Start" ;; + BeforeAgent) EVENT_TYPE="Start" ;; + AfterAgent) EVENT_TYPE="Stop" ;; + AfterTool) EVENT_TYPE="Start" ;; + SessionStart|SessionEnd) ;; *) - # Unknown event — output required JSON and exit printf '{}\n' exit 0 ;; esac -# Output required JSON response immediately to avoid blocking the agent printf '{}\n' -# Skip notification if not inside a Superset terminal +V1_EVENT_TYPE="$EVENT_TYPE" +case "$V1_EVENT_TYPE" in + SessionStart) V1_EVENT_TYPE="Start" ;; + SessionEnd) V1_EVENT_TYPE="Stop" ;; +esac + +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_TERMINAL_ID" ]; then + PAYLOAD="{\"json\":{\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\",\"agent\":{\"agentId\":\"$(json_escape "$SUPERSET_AGENT_ID")\",\"sessionId\":\"$(json_escape "$HOOK_SESSION_ID")\"}}}" + + STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ + --connect-timeout 2 --max-time 5 \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + -o /dev/null -w "%{http_code}" 2>/dev/null) + + case "$STATUS_CODE" in + 2*) exit 0 ;; + *) echo "[gemini-hook] host-service dispatch failed status=$STATUS_CODE; falling back to v1" >&2 ;; + esac +fi + [ -z "$SUPERSET_TAB_ID" ] && exit 0 curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ @@ -33,7 +53,9 @@ curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ --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 "sessionId=$HOOK_SESSION_ID" \ + --data-urlencode "hookSessionId=$HOOK_SESSION_ID" \ + --data-urlencode "eventType=$V1_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/agent-setup/templates/notify-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh index 7259ad9c509..3073abd42d4 100644 --- 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 @@ -1,17 +1,16 @@ #!/bin/bash {{MARKER}} -# Called by CLI agents (Claude Code, Codex, etc.) when they complete or need input +# CLI agent lifecycle hook — POSTs an AgentIdentity payload to the v2 +# host-service endpoint, with a v1 Electron hook fallback while both +# terminal stacks are supported. -# Get JSON input - Codex passes as argument, Claude pipes to stdin +# Codex passes JSON as argv; Claude/Mastra/Droid pipe via stdin. if [ -n "$1" ]; then INPUT="$1" else INPUT=$(cat) fi -# Extract Mastra identifiers when available (mastracode hooks) -# `resourceId` / `resource_id` is the Superset chat session id we assign via -# harness.setResourceId(...). `session_id` is Mastra's internal runtime id. HOOK_SESSION_ID=$(echo "$INPUT" | grep -oE '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') RESOURCE_ID=$(echo "$INPUT" | grep -oE '"resourceId"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') if [ -z "$RESOURCE_ID" ]; then @@ -19,77 +18,63 @@ if [ -z "$RESOURCE_ID" ]; then fi SESSION_ID=${RESOURCE_ID:-$HOOK_SESSION_ID} -# v2 terminal hooks identify the runtime by terminalId. The v1 fallback still -# uses pane/tab/session fields, so keep its legacy guard when no host-service -# hook URL is available. -if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then - [ -z "$SUPERSET_TERMINAL_ID" ] && exit 0 -else - [ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && exit 0 -fi - -# Extract event type - Claude uses "hook_event_name", Codex uses "type" -# Use flexible pattern to handle optional whitespace: "key": "value" or "key":"value" +# Claude/Mastra/Droid use "hook_event_name"; Codex uses "type". 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 when no native hook_event_name is present. CODEX_TYPE=$(echo "$INPUT" | grep -oE '"type"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') case "$CODEX_TYPE" in - agent-turn-complete|task_complete) - EVENT_TYPE="Stop" - ;; - task_started) - EVENT_TYPE="Start" - ;; + agent-turn-complete|task_complete) EVENT_TYPE="Stop" ;; + task_started) EVENT_TYPE="Start" ;; exec_approval_request|apply_patch_approval_request|request_user_input) EVENT_TYPE="PermissionRequest" ;; esac 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). - -# Only UserPromptSubmit is mapped here; other events are normalized -# server-side by mapEventType() to keep a single source of truth. +# UserPromptSubmit normalizes here; other aliases are mapped server-side +# by mapEventType so the wire stays a single source of truth. [ "$EVENT_TYPE" = "UserPromptSubmit" ] && EVENT_TYPE="Start" -# If no event type was found, skip the notification -# This prevents parse failures from causing false completion notifications +# Never default to "Stop" on parse failure — silent drop is safer than +# a false completion notification. [ -z "$EVENT_TYPE" ] && exit 0 DEBUG_HOOKS_ENABLED="0" if [ -n "$SUPERSET_DEBUG_HOOKS" ]; then case "$SUPERSET_DEBUG_HOOKS" in - 1|true|TRUE|True|yes|YES|on|ON) - DEBUG_HOOKS_ENABLED="1" - ;; - *) - DEBUG_HOOKS_ENABLED="0" - ;; + 1|true|TRUE|True|yes|YES|on|ON) DEBUG_HOOKS_ENABLED="1" ;; esac elif [ "$SUPERSET_ENV" = "development" ] || [ "$NODE_ENV" = "development" ]; then DEBUG_HOOKS_ENABLED="1" fi if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then - echo "[notify-hook] event=$EVENT_TYPE terminalId=$SUPERSET_TERMINAL_ID sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID paneId=$SUPERSET_PANE_ID tabId=$SUPERSET_TAB_ID workspaceId=$SUPERSET_WORKSPACE_ID" >&2 + echo "[notify-hook] event=$EVENT_TYPE terminalId=$SUPERSET_TERMINAL_ID agentId=$SUPERSET_AGENT_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID paneId=$SUPERSET_PANE_ID tabId=$SUPERSET_TAB_ID workspaceId=$SUPERSET_WORKSPACE_ID" >&2 fi -# Escape backslashes and double quotes for safe JSON embedding. +debug_log() { + [ "$DEBUG_HOOKS_ENABLED" = "1" ] || return 0 + printf '%s [notify-hook] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date)" "$*" >> "${SUPERSET_HOOK_DEBUG_LOG:-/tmp/superset-agent-hooks.log}" 2>/dev/null || true +} + +debug_log "event=$EVENT_TYPE terminalId=$SUPERSET_TERMINAL_ID agentId=$SUPERSET_AGENT_ID sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID tabId=$SUPERSET_TAB_ID" + +V1_EVENT_TYPE="$EVENT_TYPE" +case "$V1_EVENT_TYPE" in + Attached|attached|SessionStart|sessionStart|session_start) + V1_EVENT_TYPE="Start" + ;; + Detached|detached|SessionEnd|sessionEnd|session_end) + V1_EVENT_TYPE="Stop" + ;; +esac + json_escape() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' } -# v2: host-service tRPC endpoint. The renderer subscribes over the event -# bus and plays the ringtone. Preferred when the URL is provided by -# host-service's terminal env. Endpoint is unauthenticated — it only -# broadcasts chimes, no auth header needed. Always captures the status -# so we can fall back to v1 when host-service is unreachable or the -# mutation returns non-2xx (restarts, crashes, transient errors). -if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then - PAYLOAD="{\"json\":{\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\"}}" +if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ] && [ -n "$SUPERSET_TERMINAL_ID" ]; then + PAYLOAD="{\"json\":{\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\",\"agent\":{\"agentId\":\"$(json_escape "$SUPERSET_AGENT_ID")\",\"sessionId\":\"$(json_escape "$SESSION_ID")\"}}}" STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ --connect-timeout 2 --max-time 5 \ @@ -100,15 +85,16 @@ if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then echo "[notify-hook] host-service dispatched status=$STATUS_CODE" >&2 fi + debug_log "host-service status=$STATUS_CODE url=$SUPERSET_HOST_AGENT_HOOK_URL" case "$STATUS_CODE" in 2*) exit 0 ;; esac fi -# v1 fallback: electron localhost server. Used by v1 terminals and when -# host-service is unreachable from the agent's shell. -# Timeouts prevent blocking agent completion if notification server is unresponsive +# v1 fallback: Electron localhost hook server. Kept while v1 terminals exist. +[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && exit 0 + if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then STATUS_CODE=$(curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ --connect-timeout 1 --max-time 2 \ @@ -118,12 +104,14 @@ if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then --data-urlencode "sessionId=$SESSION_ID" \ --data-urlencode "hookSessionId=$HOOK_SESSION_ID" \ --data-urlencode "resourceId=$RESOURCE_ID" \ - --data-urlencode "eventType=$EVENT_TYPE" \ + --data-urlencode "eventType=$V1_EVENT_TYPE" \ --data-urlencode "env=$SUPERSET_ENV" \ --data-urlencode "version=$SUPERSET_HOOK_VERSION" \ -o /dev/null -w "%{http_code}" 2>/dev/null) - echo "[notify-hook] dispatched status=$STATUS_CODE" >&2 + echo "[notify-hook] v1 dispatched status=$STATUS_CODE" >&2 + debug_log "v1 status=$STATUS_CODE port=${SUPERSET_PORT:-{{DEFAULT_PORT}}}" else + debug_log "v1 dispatch port=${SUPERSET_PORT:-{{DEFAULT_PORT}}}" 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" \ @@ -132,7 +120,7 @@ else --data-urlencode "sessionId=$SESSION_ID" \ --data-urlencode "hookSessionId=$HOOK_SESSION_ID" \ --data-urlencode "resourceId=$RESOURCE_ID" \ - --data-urlencode "eventType=$EVENT_TYPE" \ + --data-urlencode "eventType=$V1_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/agent-setup/templates/opencode-plugin.template.js b/apps/desktop/src/main/lib/agent-setup/templates/opencode-plugin.template.js index 42b5a42f623..668fce53f95 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 @@ -26,8 +26,8 @@ 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 {}; + // Only run inside a v2 Superset terminal session. + if (!process?.env?.SUPERSET_TERMINAL_ID) return {}; const notifyPath = "{{NOTIFY_PATH}}"; const debug = process?.env?.SUPERSET_DEBUG === '1'; @@ -49,6 +49,9 @@ export const SupersetNotifyPlugin = async ({ $, client }) => { const payload = JSON.stringify({ hook_event_name: hookEventName }); log('Sending notification:', hookEventName); try { + // SUPERSET_AGENT_ID=opencode is exported by the opencode wrapper and + // inherited by every child of the opencode process, so the notify + // script reads the right id from env without any explicit forwarding. await $`bash ${notifyPath} ${payload}`; log('Notification sent successfully'); } catch (err) { @@ -144,9 +147,40 @@ export const SupersetNotifyPlugin = async ({ $, client }) => { return { event: async ({ event }) => { - const sessionID = event.properties?.sessionID; + // session.created carries the new session info under `info`, not `sessionID`. + const sessionID = + event.properties?.sessionID ?? + event.properties?.info?.id ?? + null; log('Event:', event.type, 'sessionID:', sessionID); + // SessionStart/SessionEnd give the host binding store its earliest/latest + // signal — fired before any prompt arrives. Filter out child sessions so + // subagent spawns don't change the pane icon. + if (event.type === "session.created") { + const isChild = Boolean(event.properties?.info?.parentID); + // Cache eagerly so session.deleted can resolve isChild synchronously + // — by the time deletion fires the session is gone from list(). + if (sessionID) childSessionCache.set(sessionID, isChild); + if (!isChild) { + await notify("SessionStart"); + } + return; + } + if (event.type === "session.deleted") { + const cachedIsChild = + sessionID != null ? childSessionCache.get(sessionID) : undefined; + const isChild = + cachedIsChild !== undefined + ? cachedIsChild + : await isChildSession(sessionID); + if (!isChild) { + await notify("SessionEnd"); + } + if (sessionID) childSessionCache.delete(sessionID); + return; + } + // Skip notifications for child/subagent sessions if (await isChildSession(sessionID)) { log('Skipping child session'); diff --git a/apps/desktop/src/main/lib/agent-setup/templates/pi-extension.template.ts b/apps/desktop/src/main/lib/agent-setup/templates/pi-extension.template.ts index 36247e288fd..9c922a5c844 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/pi-extension.template.ts +++ b/apps/desktop/src/main/lib/agent-setup/templates/pi-extension.template.ts @@ -10,12 +10,12 @@ * pi `before_agent_start` → Claude `UserPromptSubmit` → Superset `Start` * pi `tool_execution_end` → Claude `PostToolUse` → progress signal * pi `agent_end` → Claude `Stop` → completion / chime + * pi `session_end` → Claude `SessionEnd` → pane icon detach * pi `session_shutdown` → Claude `Stop` → cleanup on quit/reload * - * Activates only when running inside a Superset terminal (detected via - * SUPERSET_TERMINAL_ID / SUPERSET_TAB_ID / SUPERSET_PANE_ID). Outside - * Superset it's a complete no-op. If notify.sh is missing it's also a - * no-op (Superset uninstalled / never installed). + * Activates only when running inside a v2 Superset terminal (detected via + * SUPERSET_TERMINAL_ID). Outside Superset it's a complete no-op. If notify.sh + * is missing it's also a no-op (Superset uninstalled / never installed). * * Hook dispatch is fire-and-forget: failures to spawn or curl never * affect the agent loop. notify.sh has its own connect/max timeouts. @@ -28,14 +28,8 @@ import { homedir } from "node:os"; import { join } from "node:path"; export default function (pi: ExtensionAPI) { - // Only activate inside a Superset terminal. Both v2 (host-service) and - // v1 (electron localhost) shells set at least one of these. - const insideSuperset = Boolean( - process.env.SUPERSET_TERMINAL_ID || - process.env.SUPERSET_TAB_ID || - process.env.SUPERSET_PANE_ID, - ); - if (!insideSuperset) return; + // Only activate inside a v2 Superset terminal. + if (!process.env.SUPERSET_TERMINAL_ID) return; const supersetHome = process.env.SUPERSET_HOME_DIR || join(homedir(), ".superset"); @@ -47,7 +41,7 @@ export default function (pi: ExtensionAPI) { const child = spawn(notifyScript, [], { stdio: ["pipe", "ignore", "ignore"], detached: true, - env: process.env, + env: { ...process.env, SUPERSET_AGENT_ID: "pi" }, }); child.on("error", () => { /* swallow — never let hook failures affect pi */ @@ -73,6 +67,19 @@ export default function (pi: ExtensionAPI) { // that's a niche regression; on >=0.38.0 the gate works precisely. const skip = (ctx: { hasUI?: boolean }) => ctx.hasUI === false; + // Earliest signal pi is alive in this terminal — pi-mono fires + // `session_start` once per session before any prompt arrives, which lets + // the host bind the pane icon before the user types. + pi.on("session_start", (_event, ctx) => { + if (skip(ctx)) return; + fire("SessionStart"); + }); + + pi.on("session_end", (_event, ctx) => { + if (skip(ctx)) return; + fire("SessionEnd"); + }); + pi.on("before_agent_start", (_event, ctx) => { if (skip(ctx)) return; fire("UserPromptSubmit"); diff --git a/apps/desktop/src/renderer/lib/preset-icon.ts b/apps/desktop/src/renderer/lib/preset-icon.ts new file mode 100644 index 00000000000..3e8ce539226 --- /dev/null +++ b/apps/desktop/src/renderer/lib/preset-icon.ts @@ -0,0 +1,34 @@ +import type { HostAgentConfig } from "@superset/host-service/settings"; +import { getPresetIcon } from "renderer/assets/app-icons/preset-icons"; + +interface PresetWithAgent { + agentId?: string; +} + +/** + * Resolves the preset-icon key for a v2 terminal preset. + * + * v2 preset rows store the linked host-agent config id in `agentId` (a UUID), + * not the icon key. The icon key lives on the agent as `presetId` + * (e.g. `"cursor-agent"`), so the canonical resolution is + * `agentId → agent → agent.presetId → icon`. Falls back to `agentId` itself + * for legacy v2 rows whose `agentId` still holds a presetId. + * + * Never resolve by `preset.name` — it's user-editable display text and would + * silently break for any label with spaces, casing differences, or edits. + */ +export function resolveV2PresetIcon( + preset: PresetWithAgent, + agents: HostAgentConfig[] | undefined, + isDark: boolean, +): string | undefined { + if (!preset.agentId) return undefined; + const linkedAgentPresetId = + agents?.find((agent) => agent.id === preset.agentId)?.presetId ?? + agents?.find((agent) => agent.presetId === preset.agentId)?.presetId; + return ( + (linkedAgentPresetId + ? getPresetIcon(linkedAgentPresetId, isDark) + : undefined) ?? getPresetIcon(preset.agentId, isDark) + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx index 83399e64f96..e3352bd9cca 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx @@ -12,14 +12,14 @@ import { useNavigate } from "@tanstack/react-router"; import { Eye, EyeOff, Settings } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { HiMiniCommandLine } from "react-icons/hi2"; -import { - getPresetIcon, - useIsDarkTheme, -} from "renderer/assets/app-icons/preset-icons"; +import { useIsDarkTheme } from "renderer/assets/app-icons/preset-icons"; import { HotkeyMenuShortcut } from "renderer/components/HotkeyMenuShortcut"; +import { useV2AgentConfigs } from "renderer/hooks/useV2AgentConfigs"; import type { HotkeyId } from "renderer/hotkeys"; +import { resolveV2PresetIcon } from "renderer/lib/preset-icon"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { V2PresetBarItem } from "./components/V2PresetBarItem"; interface V2PresetsBarProps { @@ -72,6 +72,8 @@ export function V2PresetsBar({ const navigate = useNavigate(); const isDark = useIsDarkTheme(); const collections = useCollections(); + const { activeHostUrl } = useLocalHostService(); + const { data: agents } = useV2AgentConfigs(activeHostUrl); const [localVisiblePresetIds, setLocalVisiblePresetIds] = useState( () => getVisiblePresetOrder(matchedPresets), @@ -210,7 +212,7 @@ export function V2PresetsBar({ {matchedPresets.map((preset) => { - const icon = getPresetIcon(preset.name, isDark); + const icon = resolveV2PresetIcon(preset, agents, isDark); const isVisible = isPresetVisibleInBar(preset.pinnedToBar); const visibleIndex = visiblePresetIndexById.get(preset.id); const hotkeyId = @@ -276,6 +278,7 @@ export function V2PresetsBar({ visibleIndex={visibleIndex} hotkeyId={hotkeyId} isDark={isDark} + agents={agents} onExecutePreset={executePreset} onEdit={(presetToEdit) => handleEditPreset(presetToEdit.id)} onLocalReorder={handleLocalVisibleReorder} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/V2PresetBarItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/V2PresetBarItem.tsx index ec7374bad4c..c09858c6c54 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/V2PresetBarItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/V2PresetBarItem.tsx @@ -1,3 +1,4 @@ +import type { HostAgentConfig } from "@superset/host-service/settings"; import { Button } from "@superset/ui/button"; import { ContextMenu, @@ -10,9 +11,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useEffect, useRef } from "react"; import { useDrag, useDrop } from "react-dnd"; import { HiMiniCommandLine } from "react-icons/hi2"; -import { getPresetIcon } from "renderer/assets/app-icons/preset-icons"; import type { HotkeyId } from "renderer/hotkeys"; import { HotkeyLabel } from "renderer/hotkeys"; +import { resolveV2PresetIcon } from "renderer/lib/preset-icon"; import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; const V2_PRESET_BAR_ITEM_TYPE = "V2_PRESET_BAR_ITEM"; @@ -22,6 +23,7 @@ interface V2PresetBarItemProps { visibleIndex: number; hotkeyId?: HotkeyId; isDark: boolean; + agents: HostAgentConfig[] | undefined; onExecutePreset: (preset: V2TerminalPresetRow) => void; onEdit: (preset: V2TerminalPresetRow) => void; onLocalReorder: (fromIndex: number, toIndex: number) => void; @@ -33,13 +35,14 @@ export function V2PresetBarItem({ visibleIndex, hotkeyId, isDark, + agents, onExecutePreset, onEdit, onLocalReorder, onPersistReorder, }: V2PresetBarItemProps) { const containerRef = useRef(null); - const icon = getPresetIcon(preset.name, isDark); + const icon = resolveV2PresetIcon(preset, agents, isDark); const label = preset.description || preset.name || "default"; const [{ isDragging }, drag] = useDrag( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalPaneIcon/TerminalPaneIcon.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalPaneIcon/TerminalPaneIcon.tsx new file mode 100644 index 00000000000..a6dab14929c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalPaneIcon/TerminalPaneIcon.tsx @@ -0,0 +1,40 @@ +import { BUILTIN_AGENT_LABELS } from "@superset/shared/agent-catalog"; +import { TerminalSquare } from "lucide-react"; +import { usePresetIcon } from "renderer/assets/app-icons/preset-icons"; +import { + selectV2AgentBinding, + useV2AgentBindingStore, +} from "renderer/stores/v2-agent-bindings"; + +interface TerminalPaneIconProps { + terminalId: string; +} + +/** + * Pane icon that swaps in the running agent's logo when the v2 lifecycle hook + * has detected one in this terminal. Falls back to the generic terminal glyph + * when no agent is bound or the agent id has no preset icon. + */ +export function TerminalPaneIcon({ terminalId }: TerminalPaneIconProps) { + const binding = useV2AgentBindingStore(selectV2AgentBinding(terminalId)); + const agentId = binding?.identity.agentId; + const iconSrc = usePresetIcon(agentId ?? ""); + + if (agentId && iconSrc) { + const label = + (agentId in BUILTIN_AGENT_LABELS && + BUILTIN_AGENT_LABELS[agentId as keyof typeof BUILTIN_AGENT_LABELS]) || + agentId; + return ( + {label} + ); + } + + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalPaneIcon/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalPaneIcon/index.ts new file mode 100644 index 00000000000..be3ec794976 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalPaneIcon/index.ts @@ -0,0 +1 @@ +export { TerminalPaneIcon } from "./TerminalPaneIcon"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx index 3da9cae0d1d..7cfa912b156 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -9,14 +9,7 @@ import { } from "@superset/ui/dropdown-menu"; import { toast } from "@superset/ui/sonner"; import { workspaceTrpc } from "@superset/workspace-client"; -import { - Check, - ChevronDown, - LoaderCircle, - Plus, - TerminalSquare, - Trash2, -} from "lucide-react"; +import { Check, ChevronDown, LoaderCircle, Plus, Trash2 } from "lucide-react"; import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; @@ -25,6 +18,7 @@ import type { TerminalPaneData, } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils"; +import { TerminalPaneIcon } from "../TerminalPaneIcon"; interface TerminalSessionDropdownProps { context: RendererContext; @@ -246,7 +240,7 @@ export function TerminalSessionDropdown({ onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} > - + {triggerTitle} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index b5ad687ee29..48d5723f1d0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -7,13 +7,7 @@ import { alert } from "@superset/ui/atoms/Alert"; import { toast } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; import { workspaceTrpc } from "@superset/workspace-client"; -import { - Circle, - GitCompareArrows, - Globe, - MessageSquare, - TerminalSquare, -} from "lucide-react"; +import { Circle, GitCompareArrows, Globe, MessageSquare } from "lucide-react"; import { useMemo } from "react"; import { LuArrowDownToLine, @@ -58,6 +52,7 @@ import { FilePane } from "./components/FilePane"; import { FilePaneHeaderExtras } from "./components/FilePane/components/FilePaneHeaderExtras"; import { TerminalPane } from "./components/TerminalPane"; import { TerminalHeaderExtras } from "./components/TerminalPane/components/TerminalHeaderExtras"; +import { TerminalPaneIcon } from "./components/TerminalPane/components/TerminalPaneIcon"; import { TerminalSessionDropdown } from "./components/TerminalPane/components/TerminalSessionDropdown"; function getFileName(filePath: string): string { @@ -241,7 +236,10 @@ export function usePaneRegistry({ ), }, terminal: { - getIcon: () => , + getIcon: (ctx) => { + const { terminalId } = ctx.pane.data as TerminalPaneData; + return ; + }, getTitle: () => "Terminal", titleSource: (pane) => { const { terminalId } = pane.data as TerminalPaneData; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts index bbccedbf0d4..76b4def144a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts @@ -1,9 +1,11 @@ +import type { HostAgentConfig } from "@superset/host-service/settings"; import type { CreatePaneInput, WorkspaceStore } from "@superset/panes"; import { toast } from "@superset/ui/sonner"; import { useLiveQuery } from "@tanstack/react-db"; import { useCallback, useMemo } from "react"; import { useV2AgentConfigs } from "renderer/hooks/useV2AgentConfigs"; import { buildAgentLaunchCommand } from "renderer/lib/agent-launch-command"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { useWorkspace } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceProvider"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; @@ -29,6 +31,17 @@ function resolveTarget(executionMode: V2TerminalPresetRow["executionMode"]) { return executionMode === "split-pane" ? "active-tab" : "new-tab"; } +function findLinkedAgent( + agents: HostAgentConfig[], + agentId: string, +): HostAgentConfig | null { + return ( + agents.find((agent) => agent.id === agentId) ?? + agents.find((agent) => agent.presetId === agentId) ?? + null + ); +} + interface UseV2PresetExecutionArgs { store: StoreApi>; launcher: TerminalLauncher; @@ -56,31 +69,36 @@ export function useV2PresetExecution({ const { activeHostUrl } = useLocalHostService(); const { data: agents = [] } = useV2AgentConfigs(activeHostUrl); - // Map presetId → command (first match wins if the user has multiple - // host configs for the same preset). - const agentCommandsById = useMemo(() => { - const map = new Map(); - for (const agent of agents) { - if (agent.command.trim().length === 0) continue; - if (map.has(agent.presetId)) continue; - map.set(agent.presetId, buildAgentLaunchCommand(agent)); - } - return map; - }, [agents]); - const matchedPresets = useMemo( () => filterMatchingPresetsForProject(allPresets, projectId), [allPresets, projectId], ); const resolvePresetCommands = useCallback( - (preset: V2TerminalPresetRow): string[] => { + async (preset: V2TerminalPresetRow): Promise => { if (!preset.agentId) return preset.commands; - const live = agentCommandsById.get(preset.agentId); + + let resolveAgents = agents; + if (activeHostUrl) { + try { + resolveAgents = + await getHostServiceClientByUrl( + activeHostUrl, + ).settings.agentConfigs.list.query(); + } catch { + resolveAgents = agents; + } + } + + const linkedAgent = findLinkedAgent(resolveAgents, preset.agentId); + const live = + linkedAgent && linkedAgent.command.trim().length > 0 + ? buildAgentLaunchCommand(linkedAgent) + : undefined; if (live) return [live]; return preset.commands; }, - [agentCommandsById], + [activeHostUrl, agents], ); const executePreset = useCallback( @@ -89,7 +107,7 @@ export function useV2PresetExecution({ const activeTabId = state.activeTabId; const target = resolveTarget(preset.executionMode); const title = preset.name || undefined; - const commands = resolvePresetCommands(preset); + const commands = await resolvePresetCommands(preset); const plan = getPresetLaunchPlan({ mode: preset.executionMode, diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx index 2d06ee25d5f..29c1a6dd4b9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx @@ -10,7 +10,6 @@ import { DialogTitle, } from "@superset/ui/dialog"; import { useEffect, useRef } from "react"; -import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCloseNewWorkspaceModal, useNewWorkspaceModalOpen, @@ -44,9 +43,6 @@ export function DashboardNewWorkspaceModal() { const closeModal = useCloseNewWorkspaceModal(); const preSelectedProjectId = usePreSelectedProjectId(); - // Prevents AgentSelect from flashing "No agent" while presets load after refresh. - electronTrpc.settings.getAgentPresets.useQuery(); - return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportPresetsPage/ImportPresetsPage.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportPresetsPage/ImportPresetsPage.tsx index 988caad0cef..6de32819c74 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportPresetsPage/ImportPresetsPage.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportPresetsPage/ImportPresetsPage.tsx @@ -7,9 +7,11 @@ import { import { useLiveQuery } from "@tanstack/react-db"; import { useMemo, useState } from "react"; import { LuTerminal } from "react-icons/lu"; +import { useV2AgentConfigs } from "renderer/hooks/useV2AgentConfigs"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { ImportPageShell } from "../components/ImportPageShell"; import { ImportRow, type RowAction } from "../components/ImportRow"; @@ -23,6 +25,8 @@ export function ImportPresetsPage({ organizationId }: ImportPresetsPageProps) { const collections = useCollections(); const presetsQuery = electronTrpc.settings.getTerminalPresets.useQuery(); const [isRefreshing, setIsRefreshing] = useState(false); + const { activeHostUrl } = useLocalHostService(); + const { data: agents = [] } = useV2AgentConfigs(activeHostUrl); const { data: v2Presets = [] } = useLiveQuery( (query) => query.from({ v2TerminalPresets: collections.v2TerminalPresets }), @@ -40,6 +44,20 @@ export function ImportPresetsPage({ organizationId }: ImportPresetsPageProps) { const isLoading = presetsQuery.isPending; const presets = presetsQuery.data ?? []; + const agentConfigIdByPresetId = useMemo(() => { + const map = new Map(); + for (const agent of agents) { + if (!BUILTIN_AGENT_IDS.has(agent.presetId)) { + continue; + } + const presetId = agent.presetId as AgentType; + if (map.has(presetId)) { + continue; + } + map.set(presetId, agent.id); + } + return map; + }, [agents]); const refresh = async () => { setIsRefreshing(true); @@ -61,14 +79,18 @@ export function ImportPresetsPage({ organizationId }: ImportPresetsPageProps) { isRefreshing={isRefreshing} > {presets.map((preset, index) => { - const linkedAgentId = BUILTIN_AGENT_IDS.has(preset.name) + const builtInAgentId = BUILTIN_AGENT_IDS.has(preset.name) ? (preset.name as AgentType) : undefined; - const v2Name = linkedAgentId - ? AGENT_LABELS[linkedAgentId] + const linkedAgentId = builtInAgentId + ? (agentConfigIdByPresetId.get(builtInAgentId) ?? builtInAgentId) + : undefined; + const v2Name = builtInAgentId + ? AGENT_LABELS[builtInAgentId] : preset.name; const alreadyImported = linkedAgentId - ? importedAgentIds.has(linkedAgentId) + ? importedAgentIds.has(linkedAgentId) || + (!!builtInAgentId && importedAgentIds.has(builtInAgentId)) : importedNames.has(v2Name); return ( { + if (payload.eventType === "Detached") { + useV2AgentBindingStore.getState().clearBinding(payload.terminalId); + } else if (payload.agent) { + useV2AgentBindingStore + .getState() + .setBinding(payload.terminalId, payload.agent, payload.occurredAt); + } else { + useV2AgentBindingStore.getState().clearBinding(payload.terminalId); + } const workspace = workspacesById.get(workspaceId); if (!workspace) return; handleV2AgentLifecycleEvent({ @@ -53,6 +63,9 @@ export function HostNotificationSubscriber({ const handleTerminalLifecycle = useEffectEvent( (workspaceId: string, payload: TerminalLifecyclePayload) => { + if (payload.eventType === "exit") { + useV2AgentBindingStore.getState().clearBinding(payload.terminalId); + } const workspace = workspacesById.get(workspaceId); if (!workspace) return; handleV2TerminalLifecycleEvent({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts index 23fafcd9f70..6892ddc5eb6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts @@ -20,18 +20,10 @@ import { import { resolveV2AgentStatusTransition } from "./statusTransitions"; /** - * Handles v2 lifecycle events received by V2NotificationController. Updates - * pane status indicators (working/review/permission/idle) and plays the - * selected ringtone in the renderer. - * - * Mirrors the v1 electron-main playback path - * (apps/desktop/src/main/lib/notifications/notification-manager.ts) plus the - * v1 sidebar-status path (renderer/stores/tabs/useAgentHookListener.ts), but - * runs client-side so it works when host-service is off-machine. - * - * Keeps v1 behavior: skip `Start` for sound, suppress when the event's - * pane is visible and the window is focused, and honor the existing - * mute/volume settings. + * Updates pane status indicators (working/review/permission/idle) and plays + * the completion chime client-side, so the playback path works when + * host-service runs off-machine. The chime is suppressed when the target + * pane is visible and the window is focused. */ export function handleV2AgentLifecycleEvent({ workspaceId, @@ -53,7 +45,17 @@ export function handleV2AgentLifecycleEvent({ }); updatePaneStatus(workspaceId, payload, target, paneLayout); - if (payload.eventType === "Start") return; + // Only Stop and PermissionRequest deserve sound. Start fires per-prompt + // (the working spinner is feedback enough); Attached/Detached fire on + // agent boot and clean exit, neither of which is a "your agent finished" + // moment. + if ( + payload.eventType === "Start" || + payload.eventType === "Attached" || + payload.eventType === "Detached" + ) { + return; + } if (shouldSuppress(target, paneLayout)) return; const ringtoneId = useRingtoneStore.getState().selectedRingtoneId; @@ -75,15 +77,6 @@ export function handleV2TerminalLifecycleEvent({ ]); } -/** - * Writes agent-lifecycle status into the v2 notification store so workspace, - * tab, and pane UI can derive attention from the same terminal source. - * - * The Stop transition mirrors v1 (useAgentHookListener.ts), but uses the v2 - * pane layout instead of workspace-level guessing: clear to idle when the - * exact target pane is visible, otherwise mark review so the sidebar surfaces - * it. - */ function updatePaneStatus( workspaceId: string, payload: AgentLifecyclePayload, @@ -116,9 +109,7 @@ function updatePaneStatus( function getCurrentWorkspaceId(): string | null { try { - // Matches both v1 `/workspace/` and v2 `/v2-workspace/` - // routes. Notifications are layout-level, so either can be active - // while an event arrives. + // Matches both `/workspace/` and `/v2-workspace/` route shapes. const match = window.location.hash.match(/\/(?:v2-)?workspace\/([^/?#]+)/); return match ? decodeURIComponent(match[1] ?? "") : null; } catch { diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.test.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.test.ts index c00073f6814..a5ddc053681 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.test.ts @@ -89,6 +89,54 @@ describe("resolveV2AgentStatusTransition", () => { }); }); + it("does not change pane status on session Attached", () => { + expect( + resolveV2AgentStatusTransition({ + workspaceId: WORKSPACE_ID, + payload: payload({ eventType: "Attached", terminalId: "terminal-1" }), + statuses: {}, + targetVisible: false, + }), + ).toEqual({ clearSources: [], setStatus: null }); + }); + + it("clears transient pane status on session Detached", () => { + for (const status of ["working", "permission"] as const) { + expect( + resolveV2AgentStatusTransition({ + workspaceId: WORKSPACE_ID, + payload: payload({ eventType: "Detached", terminalId: "terminal-1" }), + statuses: { + "terminal:terminal-1": { + workspaceId: WORKSPACE_ID, + status, + }, + }, + targetVisible: false, + }), + ).toEqual({ + clearSources: [{ type: "terminal", id: "terminal-1" }], + setStatus: null, + }); + } + }); + + it("does not clear review pane status on session Detached", () => { + expect( + resolveV2AgentStatusTransition({ + workspaceId: WORKSPACE_ID, + payload: payload({ eventType: "Detached", terminalId: "terminal-1" }), + statuses: { + "terminal:terminal-1": { + workspaceId: WORKSPACE_ID, + status: "review", + }, + }, + targetVisible: false, + }), + ).toEqual({ clearSources: [], setStatus: null }); + }); + it("ignores permission state from a different workspace", () => { expect( resolveV2AgentStatusTransition({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.ts index d6c9d614947..faebc1f242c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.ts @@ -31,6 +31,21 @@ export function resolveV2AgentStatusTransition({ const terminalSource = getV2TerminalNotificationSource(payload.terminalId); const terminalSourceKey = getV2NotificationSourceKey(terminalSource); + // Attach is an idle signal — it binds the pane icon (handled in + // HostNotificationSubscriber) but must not flip the pane to "working". + if (payload.eventType === "Attached") { + return { clearSources: [], setStatus: null }; + } + if (payload.eventType === "Detached") { + const entry = statuses[terminalSourceKey]; + const shouldClearTransient = + entry?.workspaceId === workspaceId && + (entry.status === "working" || entry.status === "permission"); + return shouldClearTransient + ? { clearSources: [terminalSource], setStatus: null } + : { clearSources: [], setStatus: null }; + } + if (payload.eventType === "Start") { return { clearSources: [], diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts index ed718db4be9..2e48bbca6bc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts @@ -103,9 +103,9 @@ export const v2TerminalPresetSchema = z.object({ executionMode: v2ExecutionModeSchema.default("new-tab"), tabOrder: z.number().int().default(0), createdAt: persistedDateSchema, - // When set, the preset is live-linked to a builtin/custom agent definition. - // The launcher and editor look up the agent's current command via - // settings.getAgentPresets; the stored `commands` array is a snapshot + // When set, the preset is live-linked to a host-service agent config id. + // Older rows may still contain a builtin preset id; the launcher/editor + // support that as a fallback. The stored `commands` array is a snapshot // fallback for when the agent is missing or disabled. agentId: z.string().optional(), }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx index dda735c99b3..c0c6843c9bd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx @@ -43,9 +43,21 @@ export function V2AgentsSettings({ const queryClient = useQueryClient(); const configsQuery = useV2AgentConfigs(activeHostUrl); - - const invalidate = () => - queryClient.invalidateQueries({ queryKey: [...QUERY_KEY, activeHostUrl] }); + const queryKey = [...QUERY_KEY, activeHostUrl] as const; + const queryFamily = { queryKey: QUERY_KEY }; + + const invalidate = () => { + void queryClient.invalidateQueries(queryFamily); + void queryClient.refetchQueries(queryFamily); + }; + + const updateCachedConfig = (updated: HostAgentConfig) => { + queryClient.setQueriesData(queryFamily, (current) => + current?.map((config) => + config.id === updated.id ? { ...config, ...updated } : config, + ), + ); + }; const addMutation = useMutation({ mutationFn: (preset: HostAgentPreset) => { @@ -74,10 +86,7 @@ export function V2AgentsSettings({ await queryClient.cancelQueries({ queryKey: [...QUERY_KEY, activeHostUrl], }); - const previous = queryClient.getQueryData([ - ...QUERY_KEY, - activeHostUrl, - ]); + const previous = queryClient.getQueryData(queryKey); if (previous) { const byId = new Map(previous.map((row) => [row.id, row])); const next = ids @@ -86,13 +95,13 @@ export function V2AgentsSettings({ return row ? { ...row, order: index } : null; }) .filter((row): row is HostAgentConfig => row !== null); - queryClient.setQueryData([...QUERY_KEY, activeHostUrl], next); + queryClient.setQueryData(queryKey, next); } return { previous }; }, onError: (err, _ids, ctx) => { if (ctx?.previous) { - queryClient.setQueryData([...QUERY_KEY, activeHostUrl], ctx.previous); + queryClient.setQueryData(queryKey, ctx.previous); } toast.error(err instanceof Error ? err.message : "Failed to reorder"); }, @@ -183,7 +192,10 @@ export function V2AgentsSettings({ DESCRIPTION_BY_PRESET_ID.get(selectedAgent.presetId) ?? "Terminal agent launch configuration" } - onChanged={invalidate} + onChanged={(updated) => { + updateCachedConfig(updated); + invalidate(); + }} onDeleted={() => { setSelectedAgentId(null); invalidate(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx index d098499ccbf..ee14f821fbb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/components/AgentDetail/AgentDetail.tsx @@ -24,7 +24,7 @@ import { useLocalHostService } from "renderer/routes/_authenticated/providers/Lo interface AgentDetailProps { config: HostAgentConfig; description: string; - onChanged: () => void; + onChanged: (updated: HostAgentConfig) => void; onDeleted: () => void; } @@ -75,7 +75,7 @@ export function AgentDetail({ activeHostUrl, ).settings.agentConfigs.update.mutate({ id: config.id, patch }); }, - onSuccess: () => onChanged(), + onSuccess: (updated) => onChanged(updated), onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to save"), }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx index 4de1833f555..b26daeee6ca 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx @@ -1,3 +1,4 @@ +import type { HostAgentConfig } from "@superset/host-service/settings"; import { normalizeExecutionMode } from "@superset/local-db"; import { Badge } from "@superset/ui/badge"; import { cn } from "@superset/ui/utils"; @@ -6,10 +7,8 @@ import { useEffect, useRef } from "react"; import { useDrag, useDrop } from "react-dnd"; import { HiMiniCommandLine } from "react-icons/hi2"; import { LuGripVertical } from "react-icons/lu"; -import { - getPresetIcon, - useIsDarkTheme, -} from "renderer/assets/app-icons/preset-icons"; +import { useIsDarkTheme } from "renderer/assets/app-icons/preset-icons"; +import { resolveV2PresetIcon } from "renderer/lib/preset-icon"; import type { TerminalPreset } from "renderer/routes/_authenticated/settings/presets/types"; import { getPresetProjectTargetLabel, @@ -26,6 +25,13 @@ interface PresetRowProps { preset: TerminalPreset; rowIndex: number; projectOptionsById: ReadonlyMap; + /** + * v2 host-agent configs. When the preset's `agentId` matches a config, + * its `presetId` (e.g. `"cursor-agent"`) is used to resolve the icon. + * Older v2 rows that still store `presetId` in `agentId` resolve via the + * `presetId` fallback. Omitted by v1 callers — no v1 row has `agentId`. + */ + agents?: HostAgentConfig[]; onEdit: (presetId: string) => void; onLocalReorder: (fromIndex: number, toIndex: number) => void; onPersistReorder: (presetId: string, targetIndex: number) => void; @@ -36,6 +42,7 @@ export function PresetRow({ preset, rowIndex, projectOptionsById, + agents, onEdit, onLocalReorder, onPersistReorder, @@ -76,13 +83,11 @@ export function PresetRow({ }, [preview, drop, drag]); const isDark = useIsDarkTheme(); - const presetAgentId = (preset as PresetWithAgent).agentId; - // Try the preset's display name first (covers v1 builtins named after the - // agent and any user preset named "Claude"). Fall back to the linked - // agent id for v2 presets imported via the Import-agent dropdown. - const presetIcon = - getPresetIcon(preset.name, isDark) ?? - (presetAgentId ? getPresetIcon(presetAgentId, isDark) : undefined); + const presetIcon = resolveV2PresetIcon( + preset as PresetWithAgent, + agents, + isDark, + ); const isWorkspaceCreation = !!preset.applyOnWorkspaceCreated; const isNewTab = !!preset.applyOnNewTab; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetEditorDialog/PresetEditorDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetEditorDialog/PresetEditorDialog.tsx index 5c50dc82d81..376d434cf13 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetEditorDialog/PresetEditorDialog.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetEditorDialog/PresetEditorDialog.tsx @@ -46,9 +46,10 @@ interface PresetEditorDialogProps { projects: PresetProjectOption[]; /** * Host-service agent configs. When provided and `preset.agentId` matches - * a config's `presetId`, the dialog renders the linked-agent branch - * (read-only command + Open in Agents settings link). v1 callers omit - * this — no v1 row has agentId, so the linked branch stays dormant. + * a config id, the dialog renders the linked-agent branch (read-only + * command + Open in Agents settings link). Older v2 rows may store presetId, + * so the resolver keeps a presetId fallback. v1 callers omit this — no v1 + * row has agentId, so the linked branch stays dormant. */ agents?: HostAgentConfig[]; open: boolean; @@ -190,13 +191,21 @@ export function PresetEditorDialog({ const linkedAgent = useMemo(() => { const presetAgentId = (preset as PresetWithAgent | null)?.agentId; if (!presetAgentId || !agents) return null; - return agents.find((agent) => agent.presetId === presetAgentId) ?? null; + return ( + agents.find((agent) => agent.id === presetAgentId) ?? + agents.find((agent) => agent.presetId === presetAgentId) ?? + null + ); }, [preset, agents]); const linkedAgentId = (preset as PresetWithAgent | null)?.agentId; const isLinked = !!linkedAgentId; - const liveCommands = linkedAgent - ? [buildAgentLaunchCommand(linkedAgent)] - : (preset?.commands ?? []); + const liveCommands = useMemo( + () => + linkedAgent + ? [buildAgentLaunchCommand(linkedAgent)] + : (preset?.commands ?? []), + [linkedAgent, preset?.commands], + ); const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); const originRoute = useSettingsOriginRoute(); const trimmedCwd = preset?.cwd.trim() ?? ""; @@ -294,55 +303,40 @@ export function PresetEditorDialog({
{isLinked ? ( - <> - - - - Linked to{" "} - - {linkedAgent?.label ?? linkedAgentId} - - . Edit the command in Agents settings. - - onOpenChange(false)} - > - - - - - - -
- {liveCommands.length > 0 ? ( - liveCommands.map((cmd) => ( -
- {cmd || "—"} -
- )) - ) : ( -
- )} -
-
- +
+
+ + onOpenChange(false)} + className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground hover:text-foreground" + > + Edit in {linkedAgent?.label ?? "agent settings"} + + +
+
+ {liveCommands.length > 0 ? ( + liveCommands.map((cmd) => ( +
+ {cmd || "—"} +
+ )) + ) : ( +
+ )} +
+ {!linkedAgent && ( +

+ The linked agent is missing or disabled. Showing the + snapshot. +

+ )} +
) : ( <> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetsTable/PresetsTable.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetsTable/PresetsTable.tsx index bd8d9e55e86..a8a4605fca4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetsTable/PresetsTable.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetsTable/PresetsTable.tsx @@ -1,3 +1,4 @@ +import type { HostAgentConfig } from "@superset/host-service/settings"; import type { TerminalPreset } from "@superset/local-db"; import { cn } from "@superset/ui/utils"; import type { RefObject } from "react"; @@ -8,6 +9,8 @@ interface PresetsTableProps { presets: TerminalPreset[]; isLoading: boolean; projectOptionsById: ReadonlyMap; + /** v2 host-agent configs, used by PresetRow to resolve the linked-agent icon. */ + agents?: HostAgentConfig[]; presetsContainerRef: RefObject; onEdit: (presetId: string) => void; onLocalReorder: (fromIndex: number, toIndex: number) => void; @@ -21,6 +24,7 @@ export function PresetsTable({ presets, isLoading, projectOptionsById, + agents, presetsContainerRef, onEdit, onLocalReorder, @@ -47,6 +51,7 @@ export function PresetsTable({ preset={preset} rowIndex={index} projectOptionsById={projectOptionsById} + agents={agents} onEdit={onEdit} onLocalReorder={onLocalReorder} onPersistReorder={onPersistReorder} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/QuickAddPresets/QuickAddPresets.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/QuickAddPresets/QuickAddPresets.tsx index cdb25f7253f..075af4ac0d7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/QuickAddPresets/QuickAddPresets.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/QuickAddPresets/QuickAddPresets.tsx @@ -12,6 +12,7 @@ import { getPresetIcon } from "renderer/assets/app-icons/preset-icons"; export interface QuickAddAgentPill { agentId: string; + iconId?: string; label: string; description: string; commands: string[]; @@ -21,6 +22,7 @@ interface QuickAddPresetsProps { pills: QuickAddAgentPill[]; isDark: boolean; isAddDisabled?: boolean; + keepOpenOnAdd?: boolean; isPillAdded: (pill: QuickAddAgentPill) => boolean; onAddPill: (pill: QuickAddAgentPill) => void; } @@ -29,6 +31,7 @@ export function QuickAddPresets({ pills, isDark, isAddDisabled, + keepOpenOnAdd, isPillAdded, onAddPill, }: QuickAddPresetsProps) { @@ -48,7 +51,7 @@ export function QuickAddPresets({ {pills.map((pill) => { const alreadyAdded = isPillAdded(pill); - const icon = getPresetIcon(pill.agentId, isDark); + const icon = getPresetIcon(pill.iconId ?? pill.agentId, isDark); return ( ( + HOST_AGENT_PRESETS.map((preset) => [preset.presetId, preset.description]), +); + interface V2PresetsSectionProps { showPresets: boolean; showQuickAdd: boolean; @@ -186,22 +187,19 @@ export function V2PresetsSection({ [serverPresets], ); - // Quick-add lists every host-configured agent. We dedupe by presetId - // (= our preset's `agentId`) so a user with multiple Claude configs gets - // one pill and so deleting a preset frees the pill again. + // One pill per host-agent config — agent.id is unique, so multiple + // Claude/Codex configs each get their own pill. const quickAddPills = useMemo(() => { - const seen = new Set(); const pills: QuickAddAgentPill[] = []; for (const agent of agents) { - if (seen.has(agent.presetId) || agent.command.trim().length === 0) { + if (agent.command.trim().length === 0) { continue; } - seen.add(agent.presetId); pills.push({ - agentId: agent.presetId, + agentId: agent.id, + iconId: agent.presetId, label: agent.label, - description: - AGENT_PRESET_DESCRIPTIONS[agent.presetId as AgentType] ?? "", + description: DESCRIPTION_BY_PRESET_ID.get(agent.presetId) ?? "", commands: [buildAgentLaunchCommand(agent)], }); } @@ -259,6 +257,27 @@ export function V2PresetsSection({ [collections.v2TerminalPresets], ); + // Migrate legacy rows whose agentId still holds a presetId. Skip when the + // presetId resolves to multiple configs — we can't pick one safely. + useEffect(() => { + if (agents.length === 0 || serverPresets.length === 0) return; + + for (const preset of serverPresets) { + const row = preset as V2TerminalPresetRow; + if (!row.agentId) continue; + if (agents.some((agent) => agent.id === row.agentId)) continue; + + const legacyMatches = agents.filter( + (agent) => agent.presetId === row.agentId, + ); + if (legacyMatches.length !== 1) continue; + const legacyMatch = legacyMatches[0]; + if (!legacyMatch) continue; + + updateV2Preset(row.id, { agentId: legacyMatch.id }); + } + }, [agents, serverPresets, updateV2Preset]); + const deleteV2Preset = useCallback( (id: string) => { collections.v2TerminalPresets.delete(id); @@ -575,6 +594,7 @@ export function V2PresetsSection({ @@ -593,6 +613,7 @@ export function V2PresetsSection({ presets={localPresets} isLoading={false} projectOptionsById={projectOptionsById} + agents={agents} presetsContainerRef={presetsContainerRef} onEdit={setEditingPreset} onLocalReorder={handleLocalReorder} diff --git a/apps/desktop/src/renderer/stores/v2-agent-bindings/index.ts b/apps/desktop/src/renderer/stores/v2-agent-bindings/index.ts new file mode 100644 index 00000000000..6f631f41cbe --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-agent-bindings/index.ts @@ -0,0 +1,6 @@ +export { + selectV2AgentBinding, + useV2AgentBindingStore, + type V2AgentBinding, + type V2AgentBindingState, +} from "./store"; diff --git a/apps/desktop/src/renderer/stores/v2-agent-bindings/store.test.ts b/apps/desktop/src/renderer/stores/v2-agent-bindings/store.test.ts new file mode 100644 index 00000000000..8aea0b0e58c --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-agent-bindings/store.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { useV2AgentBindingStore } from "./store"; + +function reset() { + useV2AgentBindingStore.setState({ byTerminalId: {} }); +} + +describe("useV2AgentBindingStore", () => { + beforeEach(reset); + + it("stores and clears identity per terminal", () => { + const { setBinding, clearBinding } = useV2AgentBindingStore.getState(); + + setBinding("term-1", { agentId: "claude", sessionId: "s1" }, 100); + expect(useV2AgentBindingStore.getState().byTerminalId["term-1"]).toEqual({ + identity: { agentId: "claude", sessionId: "s1" }, + lastEventAt: 100, + }); + + clearBinding("term-1"); + expect( + useV2AgentBindingStore.getState().byTerminalId["term-1"], + ).toBeUndefined(); + }); + + it("retains the binding across repeated events for the same session", () => { + const { setBinding } = useV2AgentBindingStore.getState(); + + setBinding("term-1", { agentId: "claude", sessionId: "s1" }, 100); + const firstRef = useV2AgentBindingStore.getState().byTerminalId["term-1"]; + setBinding("term-1", { agentId: "claude", sessionId: "s1" }, 50); + setBinding("term-1", { agentId: "claude", sessionId: "s1" }, 200); + + // Identical identity events are no-ops; the icon does not need churn. + expect(useV2AgentBindingStore.getState().byTerminalId["term-1"]).toBe( + firstRef, + ); + }); + + it("replaces the binding when sessionId changes", () => { + const { setBinding } = useV2AgentBindingStore.getState(); + + setBinding("term-1", { agentId: "claude", sessionId: "s1" }, 100); + setBinding("term-1", { agentId: "claude", sessionId: "s2" }, 200); + + expect( + useV2AgentBindingStore.getState().byTerminalId["term-1"]?.identity, + ).toEqual({ agentId: "claude", sessionId: "s2" }); + }); + + it("replaces the binding when agentId changes", () => { + const { setBinding } = useV2AgentBindingStore.getState(); + + setBinding("term-1", { agentId: "claude", sessionId: "s1" }, 100); + setBinding("term-1", { agentId: "codex", sessionId: "s1" }, 200); + + expect( + useV2AgentBindingStore.getState().byTerminalId["term-1"]?.identity + .agentId, + ).toBe("codex"); + }); + + it("ignores stale events for a different identity", () => { + const { setBinding } = useV2AgentBindingStore.getState(); + + setBinding("term-1", { agentId: "claude", sessionId: "s1" }, 100); + setBinding("term-1", { agentId: "codex", sessionId: "s2" }, 200); + setBinding("term-1", { agentId: "claude", sessionId: "s1" }, 150); + + expect( + useV2AgentBindingStore.getState().byTerminalId["term-1"]?.identity, + ).toEqual({ agentId: "codex", sessionId: "s2" }); + }); + + it("isolates bindings per terminal", () => { + const { setBinding, clearBinding } = useV2AgentBindingStore.getState(); + + setBinding("term-1", { agentId: "claude" }, 100); + setBinding("term-2", { agentId: "codex" }, 100); + clearBinding("term-1"); + + expect(useV2AgentBindingStore.getState().byTerminalId).toEqual({ + "term-2": { identity: { agentId: "codex" }, lastEventAt: 100 }, + }); + }); +}); diff --git a/apps/desktop/src/renderer/stores/v2-agent-bindings/store.ts b/apps/desktop/src/renderer/stores/v2-agent-bindings/store.ts new file mode 100644 index 00000000000..0f610a9db91 --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-agent-bindings/store.ts @@ -0,0 +1,61 @@ +import type { AgentIdentity } from "@superset/workspace-client"; +import { create } from "zustand"; + +export interface V2AgentBinding { + identity: AgentIdentity; + lastEventAt: number; +} + +export interface V2AgentBindingState { + byTerminalId: Record; + setBinding: ( + terminalId: string, + identity: AgentIdentity, + occurredAt: number, + ) => void; + clearBinding: (terminalId: string) => void; +} + +/** + * Live `terminalId → AgentIdentity` map populated from `agent:lifecycle` + * events. Replaced on a different `agentId`/`sessionId` (e.g. `claude` → + * `/exit` → `codex`), cleared on terminal exit. Not persisted — the worst + * case is a brief icon flicker until the next event. + */ +export const useV2AgentBindingStore = create((set) => ({ + byTerminalId: {}, + setBinding: (terminalId, identity, occurredAt) => + set((state) => { + const existing = state.byTerminalId[terminalId]; + if (existing && existing.lastEventAt > occurredAt) { + return state; + } + if ( + existing && + existing.identity.agentId === identity.agentId && + existing.identity.sessionId === identity.sessionId && + existing.identity.definitionId === identity.definitionId + ) { + return state; + } + return { + byTerminalId: { + ...state.byTerminalId, + [terminalId]: { identity, lastEventAt: occurredAt }, + }, + }; + }), + clearBinding: (terminalId) => + set((state) => { + if (!(terminalId in state.byTerminalId)) return state; + const next = { ...state.byTerminalId }; + delete next[terminalId]; + return { byTerminalId: next }; + }), +})); + +export function selectV2AgentBinding( + terminalId: string, +): (state: V2AgentBindingState) => V2AgentBinding | undefined { + return (state) => state.byTerminalId[terminalId]; +} diff --git a/bun.lock b/bun.lock index c17af3d7927..64b07b09897 100644 --- a/bun.lock +++ b/bun.lock @@ -1061,6 +1061,7 @@ "version": "0.1.0", "dependencies": { "@superset/host-service": "workspace:*", + "@superset/shared": "workspace:*", "@superset/workspace-fs": "workspace:*", "@tanstack/react-query": "^5.90.19", "@trpc/client": "^11.7.1", diff --git a/packages/host-service/src/events/map-event-type.test.ts b/packages/host-service/src/events/map-event-type.test.ts new file mode 100644 index 00000000000..4f93c1bf8e1 --- /dev/null +++ b/packages/host-service/src/events/map-event-type.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "bun:test"; +import { mapEventType } from "./map-event-type"; + +describe("mapEventType", () => { + it("routes session lifecycle to Attached/Detached, not Start/Stop", () => { + expect(mapEventType("SessionStart")).toBe("Attached"); + expect(mapEventType("attached")).toBe("Attached"); + expect(mapEventType("sessionStart")).toBe("Attached"); + expect(mapEventType("session_start")).toBe("Attached"); + + expect(mapEventType("SessionEnd")).toBe("Detached"); + expect(mapEventType("detached")).toBe("Detached"); + expect(mapEventType("sessionEnd")).toBe("Detached"); + expect(mapEventType("session_end")).toBe("Detached"); + }); + + it("routes per-turn cadence to Start/Stop", () => { + expect(mapEventType("UserPromptSubmit")).toBe("Start"); + expect(mapEventType("BeforeAgent")).toBe("Start"); + expect(mapEventType("PostToolUse")).toBe("Start"); + expect(mapEventType("task_started")).toBe("Start"); + + expect(mapEventType("Stop")).toBe("Stop"); + expect(mapEventType("AfterAgent")).toBe("Stop"); + expect(mapEventType("task_complete")).toBe("Stop"); + expect(mapEventType("agent-turn-complete")).toBe("Stop"); + }); + + it("routes permission events", () => { + expect(mapEventType("PermissionRequest")).toBe("PermissionRequest"); + expect(mapEventType("Notification")).toBe("PermissionRequest"); + expect(mapEventType("PreToolUse")).toBe("PermissionRequest"); + expect(mapEventType("exec_approval_request")).toBe("PermissionRequest"); + }); + + it("returns null for missing or unknown events", () => { + expect(mapEventType(undefined)).toBeNull(); + expect(mapEventType("")).toBeNull(); + expect(mapEventType("totally-made-up")).toBeNull(); + }); +}); diff --git a/packages/host-service/src/events/map-event-type.ts b/packages/host-service/src/events/map-event-type.ts index 8e69631fc94..a428b5be21c 100644 --- a/packages/host-service/src/events/map-event-type.ts +++ b/packages/host-service/src/events/map-event-type.ts @@ -1,4 +1,19 @@ -export type AgentLifecycleEventType = "Start" | "Stop" | "PermissionRequest"; +/** + * Normalized lifecycle event types broadcast over the WS event bus. + * + * - `Start` / `Stop`: per-turn working-state cadence — drives the working + * indicator and the completion chime. + * - `PermissionRequest`: agent is blocked waiting for a tool/exec decision. + * - `Attached` / `Detached`: session-lifetime signal — drives the pane icon + * binding only. NOT working state: SessionStart fires on agent boot when + * the agent is still idle waiting for input. + */ +export type AgentLifecycleEventType = + | "Start" + | "Stop" + | "PermissionRequest" + | "Attached" + | "Detached"; export function mapEventType( eventType: string | undefined, @@ -7,15 +22,30 @@ export function mapEventType( return null; } if ( - eventType === "Start" || + eventType === "Attached" || + eventType === "attached" || eventType === "SessionStart" || + eventType === "sessionStart" || + eventType === "session_start" + ) { + return "Attached"; + } + if ( + eventType === "Detached" || + eventType === "detached" || + eventType === "SessionEnd" || + eventType === "sessionEnd" || + eventType === "session_end" + ) { + return "Detached"; + } + if ( + eventType === "Start" || eventType === "UserPromptSubmit" || eventType === "PostToolUse" || eventType === "PostToolUseFailure" || eventType === "BeforeAgent" || eventType === "AfterTool" || - eventType === "sessionStart" || - eventType === "session_start" || eventType === "userPromptSubmitted" || eventType === "user_prompt_submit" || eventType === "postToolUse" || @@ -41,8 +71,6 @@ export function mapEventType( eventType === "stop" || eventType === "agent-turn-complete" || eventType === "AfterAgent" || - eventType === "sessionEnd" || - eventType === "session_end" || eventType === "task_complete" ) { return "Stop"; diff --git a/packages/host-service/src/events/types.ts b/packages/host-service/src/events/types.ts index 38ea6b11595..a26139820d2 100644 --- a/packages/host-service/src/events/types.ts +++ b/packages/host-service/src/events/types.ts @@ -1,4 +1,5 @@ import type { DetectedPort } from "@superset/port-scanner"; +import type { AgentIdentity } from "@superset/shared/agent-identity"; import type { FsWatchEvent } from "@superset/workspace-fs/host"; import type { AgentLifecycleEventType } from "./map-event-type.ts"; @@ -27,6 +28,9 @@ export interface AgentLifecycleMessage { workspaceId: string; eventType: AgentLifecycleEventType; terminalId: string; + // Absent when the hook ran without `SUPERSET_AGENT_ID` set (legacy shells + // or third-party hook configs that bypass our wrappers). + agent?: AgentIdentity; occurredAt: number; } diff --git a/packages/host-service/src/trpc/router/notifications/notifications.test.ts b/packages/host-service/src/trpc/router/notifications/notifications.test.ts index 381492d416f..220f84e2efa 100644 --- a/packages/host-service/src/trpc/router/notifications/notifications.test.ts +++ b/packages/host-service/src/trpc/router/notifications/notifications.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, mock } from "bun:test"; +import type { AgentIdentity } from "@superset/shared/agent-identity"; import type { AgentLifecycleEventType } from "../../../events"; import type { HostServiceContext } from "../../../types"; import { notificationsRouter } from "./notifications"; @@ -7,6 +8,7 @@ interface BroadcastedAgentLifecycleEvent { workspaceId: string; eventType: AgentLifecycleEventType; terminalId: string; + agent?: AgentIdentity; occurredAt: number; } @@ -103,4 +105,48 @@ describe("notificationsRouter.hook", () => { expect(findFirst).not.toHaveBeenCalled(); expect(broadcastAgentLifecycle).not.toHaveBeenCalled(); }); + + it("forwards agent identity when the hook stamps it", async () => { + const { ctx, broadcastAgentLifecycle } = createContext("workspace-1"); + + await notificationsRouter.createCaller(ctx).hook({ + terminalId: "terminal-1", + eventType: "Stop", + agent: { agentId: "claude", sessionId: "session-abc" }, + }); + + expect(broadcastAgentLifecycle).toHaveBeenCalledTimes(1); + expect(broadcastAgentLifecycle.mock.calls[0]?.[0]).toMatchObject({ + workspaceId: "workspace-1", + terminalId: "terminal-1", + eventType: "Stop", + agent: { agentId: "claude", sessionId: "session-abc" }, + }); + }); + + it("normalizes empty-string identity fields to undefined", async () => { + const { ctx, broadcastAgentLifecycle } = createContext("workspace-1"); + + await notificationsRouter.createCaller(ctx).hook({ + terminalId: "terminal-1", + eventType: "Stop", + agent: { agentId: "claude", sessionId: "" }, + }); + + const broadcast = broadcastAgentLifecycle.mock.calls[0]?.[0]; + expect(broadcast?.agent).toEqual({ agentId: "claude" }); + }); + + it("drops agent identity entirely when agentId is missing", async () => { + const { ctx, broadcastAgentLifecycle } = createContext("workspace-1"); + + await notificationsRouter.createCaller(ctx).hook({ + terminalId: "terminal-1", + eventType: "Stop", + agent: { agentId: "" }, + }); + + const broadcast = broadcastAgentLifecycle.mock.calls[0]?.[0]; + expect(broadcast?.agent).toBeUndefined(); + }); }); diff --git a/packages/host-service/src/trpc/router/notifications/notifications.ts b/packages/host-service/src/trpc/router/notifications/notifications.ts index e231ad7e24c..38378e0e318 100644 --- a/packages/host-service/src/trpc/router/notifications/notifications.ts +++ b/packages/host-service/src/trpc/router/notifications/notifications.ts @@ -1,32 +1,55 @@ +import type { AgentIdentity } from "@superset/shared/agent-identity"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { terminalSessions } from "../../../db/schema"; import { mapEventType } from "../../../events"; import { publicProcedure, router } from "../../index"; -/** - * v2 terminal hook payload. The shell hook sends only stable runtime identity; - * host-service derives workspace identity from its terminal session table. - */ +// Hook scripts emit "" for unset env vars; we coerce to undefined so the +// AgentIdentity broadcast carries only meaningful fields. +const agentIdentityInput = z + .object({ + agentId: z.string().optional(), + sessionId: z.string().optional(), + definitionId: z.string().optional(), + }) + .optional(); + const hookInput = z.object({ terminalId: z.string().optional(), eventType: z.string().optional(), + agent: agentIdentityInput, }); +function trimOrUndefined(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizeAgentIdentity( + agent: z.infer, +): AgentIdentity | undefined { + const agentId = trimOrUndefined(agent?.agentId); + if (!agentId) return undefined; + const sessionId = trimOrUndefined(agent?.sessionId); + const definitionId = trimOrUndefined(agent?.definitionId); + return { + agentId: agentId as AgentIdentity["agentId"], + ...(sessionId ? { sessionId } : {}), + ...(definitionId + ? { definitionId: definitionId as AgentIdentity["definitionId"] } + : {}), + }; +} + export const notificationsRouter = router({ /** - * Agent lifecycle hook. The agent shell script POSTs here on - * session-start / permission-request / task-complete events. We normalize - * the event type, resolve the terminal's workspace, and fan out over the - * WebSocket event bus so clients (desktop renderer, web) can play the - * finish sound themselves. + * Agent lifecycle hook. The shell hook POSTs here; we normalize, resolve + * the terminal's workspace, and fan out over the WS event bus. * - * Intentionally unauthenticated. The only thing a caller can do is - * cause clients to chime and flash a sidebar indicator — no code - * execution, no data access, no state change. Reusing the host-service - * PSK for this endpoint would leak the credential into every agent - * shell's env for zero practical gain (manifest.authToken already - * exposes it to any user-level process). + * Intentionally unauthenticated: a caller can only trigger a chime and a + * sidebar indicator. Reusing the host-service PSK would leak it into every + * agent shell's env for zero practical gain. */ hook: publicProcedure.input(hookInput).mutation(async ({ ctx, input }) => { const eventType = mapEventType(input.eventType); @@ -48,10 +71,13 @@ export const notificationsRouter = router({ return { success: true, ignored: true as const }; } + const agent = normalizeAgentIdentity(input.agent); + ctx.eventBus.broadcastAgentLifecycle({ workspaceId: terminalSession.originWorkspaceId, eventType, terminalId: input.terminalId, + ...(agent ? { agent } : {}), occurredAt: Date.now(), }); diff --git a/packages/shared/package.json b/packages/shared/package.json index 01260c5f83b..8256c406415 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -36,6 +36,10 @@ "types": "./src/agent-catalog.ts", "default": "./src/agent-catalog.ts" }, + "./agent-identity": { + "types": "./src/agent-identity.ts", + "default": "./src/agent-identity.ts" + }, "./host-agent-presets": { "types": "./src/host-agent-presets.ts", "default": "./src/host-agent-presets.ts" diff --git a/packages/shared/src/agent-identity.ts b/packages/shared/src/agent-identity.ts new file mode 100644 index 00000000000..5156abde871 --- /dev/null +++ b/packages/shared/src/agent-identity.ts @@ -0,0 +1,19 @@ +import type { AgentDefinitionId, BuiltinAgentId } from "./agent-catalog"; + +/** + * Runtime identity of an agent process detected by a Superset terminal hook. + * + * Reported by the in-shell `notify-hook.sh` script, broadcast over the + * host-service event bus, and stored in renderer state keyed by terminalId. + * + * `agentId` is the wrapper-level id. Most values match `BuiltinAgentId` and + * `PRESET_ICONS`; `droid` is managed by desktop setup but is not currently a + * built-in terminal preset. `definitionId` is the user-customized id when the + * launch path stamps it; it's reserved for a future PR — wrappers can't + * distinguish user definitions on their own. + */ +export interface AgentIdentity { + agentId: BuiltinAgentId | "droid"; + sessionId?: string; + definitionId?: AgentDefinitionId; +} diff --git a/packages/workspace-client/package.json b/packages/workspace-client/package.json index c028a4e3fe9..40700202d4a 100644 --- a/packages/workspace-client/package.json +++ b/packages/workspace-client/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@superset/host-service": "workspace:*", + "@superset/shared": "workspace:*", "@superset/workspace-fs": "workspace:*", "@tanstack/react-query": "^5.90.19", "@trpc/client": "^11.7.1", diff --git a/packages/workspace-client/src/index.ts b/packages/workspace-client/src/index.ts index 90659edeabc..efdc89244b4 100644 --- a/packages/workspace-client/src/index.ts +++ b/packages/workspace-client/src/index.ts @@ -1,6 +1,7 @@ export { useEventBus } from "./hooks/useEventBus"; export { useGitChangeEvents } from "./hooks/useGitChangeEvents"; export { + type AgentIdentity, type AgentLifecyclePayload, type EventBusHandle, type GitChangedPayload, diff --git a/packages/workspace-client/src/lib/eventBus.ts b/packages/workspace-client/src/lib/eventBus.ts index c0b1fcf6d22..10d663faf62 100644 --- a/packages/workspace-client/src/lib/eventBus.ts +++ b/packages/workspace-client/src/lib/eventBus.ts @@ -3,9 +3,12 @@ import type { ClientMessage, ServerMessage, } from "@superset/host-service/events"; +import type { AgentIdentity } from "@superset/shared/agent-identity"; import type { FsWatchEvent } from "@superset/workspace-fs/host"; import { primeRelayAffinity } from "./primeRelayAffinity"; +export type { AgentIdentity }; + type EventType = | "fs:events" | "git:changed" @@ -28,6 +31,8 @@ export interface GitChangedPayload { export interface AgentLifecyclePayload { eventType: AgentLifecycleEventType; terminalId: string; + // Absent when the hook ran without `SUPERSET_AGENT_ID` set. + agent?: AgentIdentity; occurredAt: number; } @@ -143,6 +148,7 @@ function handleMessage(state: ConnectionState, data: unknown): void { { eventType: message.eventType, terminalId: message.terminalId, + ...(message.agent ? { agent: message.agent } : {}), occurredAt: message.occurredAt, }, );