Skip to content

Latest commit

 

History

History
402 lines (343 loc) · 24.8 KB

File metadata and controls

402 lines (343 loc) · 24.8 KB

Multi-CLI hook setup

VibeBuddy is source-agnostic: any coding CLI that can run a command on its lifecycle events can feed the Mac. The Mac tags each event with its source via the ?agent= query parameter (AgentKind.fromSource), so sessions render with the right name/glyph. Adding a CLI never touches the wire model.

Supported sources (AgentKind): claude, codex, antigravity (Gemini), grok, opencode, copilot, cursor. Unknown sources fall back to Claude Code (the most common hook-compatible shape).

Qwen Code and Kimi integration has been removed. Their saved session identities remain readable, but new hooks are ignored. Install and repair leave their CLI configurations untouched. Qwen voice and model providers remain available.

The universal hook command

Every CLI runs the same fail-open forwarder on each lifecycle event; only the agent= value changes:

curl -sS --max-time 3 -X POST --data-binary @- \
  "http://127.0.0.1:9876/hook?agent=<source>" 2>/dev/null || true

The CLI pipes its event JSON on stdin. VibeBuddy reads hook_event_name, session_id, cwd, tool_name, message, and (for PostToolUse) tool_response.is_error to drive status and the stuck cue. The events we use: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Notification, Stop, SessionEnd.

Installing

The Mac app's Settings (Install / repair, per-agent Repair, Uninstall) and vibebuddyd hooks install|uninstall|status [--agent NAME] [--approval] [--statusline] both run the native HookInstaller (VibeBuddyMacCore); no Python is involved. What it guarantees:

  • One stable path. The runtime scripts are copied to ~/Library/Application Support/vibebuddy/bin/ (from the app bundle's Contents/Resources/hooks/; vibebuddyd takes --hooks-dir / VIBEBUDDY_HOOKS_DIR, then its own app bundle or checkout, then /Applications/VibeBuddyMacApp.app, prints which, and notes when they differ from the installed app's) on every install and repair, and on app launch while hooks are installed. Configs name only that path, so an app update never changes a command string — and Codex's trust, keyed to the command, survives it. Install refuses to write any config when a script is missing there.
  • Traceable writes. A config that changes is first copied to …/vibebuddy/backups/<agent>-<path key>/<file>.<UTC timestamp>-<n> (newest 10 kept, plus <file>.first, the copy from before vibebuddy's first write, never pruned), then replaced atomically (written through a symlink). …/vibebuddy/hooks-manifest.json records the entries written, per agent and config path. A repeated install changes nothing on disk. An unreadable config (Claude, Codex or Cursor) is refused, never rewritten.
  • Only our entries. Entries are recognised by command — the stable path, the old app-bundle path, a checkout path, or the early inline curl …:9876/hook — so installs migrate old entries and uninstall removes only ours. Unknown keys, key order and number text are preserved; foreign hooks keep their place.
  • Version-gated Claude events. claude --version (5 s cap, with Homebrew/~/.local/bin/the binary's own directory on PATH; VIBEBUDDY_CLAUDE_VERSION overrides) selects the events that release knows — Claude skips a whole settings.json with an unknown event name. With no version, a fresh install gets the core set and existing forwarders are kept: only a known version removes an event. Commands are always shell form with a quoted path ("…/Application Support/…/vibebuddy-forward.sh" claude), never exec-form args: Grok's [compat.claude] bridge has no args field and older Claude releases ignore it.
  • Environment. CLAUDE_CONFIG_DIR, CODEX_HOME, GROK_HOME, CURSOR_HOME, XDG_CONFIG_HOME (OpenCode) and HOME are honoured.
  • Remembered uninstall. …/vibebuddy/hooks-state.json lists agents you uninstalled; launches and updates never reinstall them. An explicit install clears the entry.
  • Codex config.toml is never written (only read for the hooks-feature warning). Cursor's hooks.json keeps "version": 1.

Per-CLI configuration

CLI source config hook style status
Claude Code claude ~/.claude/settings.json JSON hooks array; --approval gates PermissionRequest ✅ tested
Codex CLI codex ~/.codex/hooks.json (notify untouched) JSON hooks array; --approval gates PermissionRequest ✅ tested
OpenCode opencode ~/.config/opencode/ plugin Claude-compatible hooks ⚠️ template
Antigravity (Gemini) antigravity ~/.gemini/antigravity-cli/hooks.json JSON command hooks blocked: agy 1.0.5 loads but skips execution
Grok Build grok ~/.grok/hooks/vibebuddy.json JSON command hooks (camelCase envelope); sessions vibebuddy starts are hosted over ACP ✅ tested (1.0.40)
Cursor cursor ~/.cursor/hooks.json (merged, user level) JSON command hooks (camelCase event names); --approval gates preToolUse ✅ wired (3.20; IDE acceptance pending)
GitHub Copilot CLI copilot ~/.copilot/session-store.db read-only session history history only

✅ = wired and exercised. ⚠️ template = the source routing + display are done in the app; the config snippet below needs validation against the installed CLI. Antigravity's VibeBuddy decoder and source routing are ready, but the installed agy 1.0.5 binary loads hooks.json without executing command hooks, even with explicit enabled: true, PreToolUse, matcher run_command, and a trusted workspace.

Claude Code (~/.claude/settings.json)

{
  "hooks": {
    "SessionStart":     [{ "matcher": "", "hooks": [{ "type": "command", "command": "curl -sS --max-time 3 -X POST --data-binary @- 'http://127.0.0.1:9876/hook?agent=claude' 2>/dev/null || true" }] }],
    "UserPromptSubmit": [{ "matcher": "", "hooks": [{ "type": "command", "command": "curl -sS --max-time 3 -X POST --data-binary @- 'http://127.0.0.1:9876/hook?agent=claude' 2>/dev/null || true" }] }],
    "PreToolUse":       [{ "matcher": "", "hooks": [{ "type": "command", "command": "curl -sS --max-time 3 -X POST --data-binary @- 'http://127.0.0.1:9876/hook?agent=claude' 2>/dev/null || true" }] }],
    "PostToolUse":      [{ "matcher": "", "hooks": [{ "type": "command", "command": "curl -sS --max-time 3 -X POST --data-binary @- 'http://127.0.0.1:9876/hook?agent=claude' 2>/dev/null || true" }] }],
    "Notification":     [{ "matcher": "", "hooks": [{ "type": "command", "command": "curl -sS --max-time 3 -X POST --data-binary @- 'http://127.0.0.1:9876/hook?agent=claude' 2>/dev/null || true" }] }],
    "Stop":             [{ "matcher": "", "hooks": [{ "type": "command", "command": "curl -sS --max-time 3 -X POST --data-binary @- 'http://127.0.0.1:9876/hook?agent=claude' 2>/dev/null || true" }] }],
    "SessionEnd":       [{ "matcher": "", "hooks": [{ "type": "command", "command": "curl -sS --max-time 3 -X POST --data-binary @- 'http://127.0.0.1:9876/hook?agent=claude' 2>/dev/null || true" }] }]
  }
}

OpenCode follows the same shape with agent=<their source>. Antigravity uses a Gemini plugin that shells out to the same curl. Copilot CLI is integrated through its local history database, following Wake's adapter. The Mac reads sessions with turns, their project/branch/title, and the latest 12 dialogue entries (600 characters each). It checks database and WAL changes every five seconds, using a private temporary copy and never changing Copilot files. Rows are marked History · read only; no live task state, completion alert, remote approval, reply, resume, or quota is inferred. A CLI installation with no stored turns contributes no rows. VibeBuddy keeps no history index or archive; this is a bounded dashboard integration.

Remote approval (--approval)

--approval replaces the asynchronous PermissionRequest status group with a blocking hooks/approval-hook.sh claude 60 (timeout: 75, matcher *). Claude fires PermissionRequest only when it would stop and ask — a prompt in default mode, an uncertain classifier in auto mode — and honours the hook's hookSpecificOutput.decision.behavior (allow / deny + message); Claude Code 2.1.261 validates exactly that shape. Every other tool call never reaches the phone. Silence leaves Claude's own prompt in place: at once when the session's terminal is in front and in use, otherwise after up to 60 s (the 60 is the hold the command passes; WR-11) — presence is read again every second during that wait, so coming back to the terminal hands the prompt back within a second — and after 25 s for a gate installed before the hold existed; bypassPermissions fires the event but ignores the answer. The PreToolUse status forwarder stays asynchronous. An older gate on PreToolUse (every call held) is migrated by a plain install; on a Claude Code older than 2.1.257 (which does not honour the decision reply) the installer keeps the gate on PreToolUse and says so (VIBEBUDDY_CLAUDE_VERSION overrides the probe).

The daemon never re-runs Claude's permissions.allow on a PermissionRequest — Claude evaluated them and still asked — so the card is always a real wait; native deny rules and vibebuddy's own store/session allows still answer at once. "Always allow" on the phone echoes Claude's permission_suggestions back as decision.updatedPermissions, so Claude Code writes the rule itself (ADR-0010, amended).

Questions (AskUserQuestion)

--approval also adds a blocking PreToolUse group with matcher AskUserQuestion (same approval-hook.sh claude 60, timeout: 75). The daemon shows the questions on the phone and Mac cards; an answer within the same wait goes back as hookSpecificOutput.updatedInput — the original questions plus answers keyed by question text (an array for a multi-select, the typed text for "Other") — so Claude continues without its own prompt. Silence prints nothing: Claude shows its question UI, the card stays, and a later answer is typed into a tmux pane when the session has one.

Presence

Both blocking paths (the PermissionRequest gate and the AskUserQuestion relay) first ask the Mac app whether you are at the keyboard for that session — its terminal in front, screen unlocked, input within two minutes. If so the hook prints nothing at once so Claude's own prompt takes the answer, and the phone shows the request as a read-only card that clears when Claude moves on. Settings → "Always ask the phone first" turns this off. A headless vibebuddyd never claims presence.

Status line (statusLine)

Install also points Claude's statusLine.command at hooks/vibebuddy-statusline.sh. Claude runs it on every event with its session JSON on stdin; the wrapper copies that JSON to the daemon's /statusline (background, 1s cap, bearer token, fail-open) and then runs the status line command that was configured before, with the same stdin, printing its output — the terminal display is unchanged. The original object is saved under ~/Library/Application Support/vibebuddy/statusline-original.{json,cmd} and uninstall restores it (or removes the key when there was none). Each Claude config directory (CLAUDE_CONFIG_DIR) has its own saved original, statusline-original.<path key>.{json,cmd}, and the wrapper is installed as "…/vibebuddy-statusline.sh" <path key> so it runs the right one. The wrapper is never saved as its own original (an older wrapper path is only re-pointed), and the script refuses to run a saved command that names itself, so wrapping can never recurse. When no saved original exists, uninstall leaves the status line in place and says so rather than delete it. The daemon uses the sample for context, cost, session name, effort, PR and worktree on the session row, and for the 5-hour / 7-day allowance; claude -p /usage only runs when no sample has arrived for 15 minutes.

Codex CLI (~/.codex/hooks.json)

vibebuddyd hooks install --agent codex              # status hooks (12 events) + terminal capture
vibebuddyd hooks install --agent codex --approval   # + the blocking phone-approval gate
vibebuddyd hooks uninstall --agent codex            # revert
vibebuddyd hooks status                             # includes what Codex will actually run

Codex reads a Claude-compatible hooks object and pipes Claude-shaped JSON to each command, so the forwarder is the same script with codex as its argument. Re-trust the entries via /hooks in a fresh session after any change.

Remote approval (--approval)

Codex honours a hook allow only on PermissionRequest — its PreToolUse accepts deny alone — and it fires PermissionRequest only when it would prompt (shell escalation, a patch outside the sandbox, managed network). --approval therefore replaces just that group with a blocking hooks/approval-hook.sh codex (timeout: 30) posting to /approval?agent=codex; the daemon replies {"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"|"deny"}}}, and a phone decision is final. Silence (no phone answer in 25s) falls back to Codex's own prompt. apply_patch is decided as Edit, with a file_path from the patch when it names one file. This describes the CLI approval path. Desktop lifecycle/tool hooks arrived in the 0.153.4 acceptance, but the tested native escalation did not produce an answerable approval card. Hook delivery does not establish Desktop approval coverage; see the Codex integration contract.

Grok Build (~/.grok/hooks/vibebuddy.json)

vibebuddyd hooks install --agent grok              # write ~/.grok/hooks/vibebuddy.json
vibebuddyd hooks install --agent grok --approval   # + the blocking phone-approval gate
vibebuddyd hooks uninstall --agent grok            # revert

Grok loads every ~/.grok/hooks/*.json file, so vibebuddy owns its own file and never edits the user's. Reload without restarting: /hooks → r. Grok's http hooks refuse loopback (SSRF guard), so these are command hooks piping the event JSON into hooks/vibebuddy-forward.sh grok.

Installed events (grok's config keys; the wire values are snake_case):

Family Events What vibebuddy does with them
lifecycle SessionStart, SessionEnd open the session (with modelId and transcriptPath), then drop it
turn UserPromptSubmit, Stop, StopFailure, StopCancelled working → done; StopFailure shows the session as stuck
tool PreToolUse, PostToolUse, PostToolUseFailure active tool and the stuck cue
attention Notification permission_prompt → needs-you; idle_prompt → the idle backstop
topology SubagentStart, SubagentStop child-agent rows under the parent session

Grok-specific decoding rules (GrokParser):

  • Stop fires a second time at teardown with reason channel_closed/shutdown. Only end_turn (or a missing reason) settles a turn; SessionEnd reports the rest.
  • StopCancelled is dispatched off the command loop, so it can land after the next turn's UserPromptSubmit. Every event carries promptId, which the reducer keeps as HookEvent.turnID and uses to drop a report for a superseded turn.
  • Notification's task_complete means a background task finished, which can happen mid-turn, so it is not a turn end and is ignored.
  • Everything that can fire inside a subagent's own session carries subagentType there and omits it in the main session; those events are dropped so a child never moves the parent's status. SubagentStart fires in the parent and SubagentStop in the child, both keyed by the same subagentId, so the child's row still completes.
  • Grok also imports ~/.claude/settings.json hooks via [compat.claude]. Those entries deliver the Claude shape without ?agent=grok and currently fail fail-open with required env var(s) not set: ${PPID} — harmless noise, never relied on. The Claude approval gate (approval-hook.sh claude 60 since WR-11) has arguments, so Grok would run it; the script exits at once when GROK_HOOK_EVENT is set and its source is not grok, so only Grok's own gate asks for a Grok session.

Remote approval (--approval)

--approval replaces the fire-and-forget PreToolUse group with a blocking hooks/approval-hook.sh grok (timeout: 30, no matcher = every tool), which posts to /approval?agent=grok and answers grok's gate.

A phone decision is authoritative only when grok runs with [ui] permission_mode = "always-approve" (permissionMode reads bypassPermissions on the wire). In grok's default mode a hook allow only means "not blocked" — grok still shows its own TUI prompt afterwards, and that prompt has no external answer channel. In that mode vibebuddy can still surface the wait (the permission_prompt notification) and deny, but the approval must be tapped on the Mac.

Hosted over ACP (sessions vibebuddy starts)

A Grok session started from the phone or the Mac's "New task" is not a hook session at all: GrokACPMonitor spawns grok agent --no-leader stdio and speaks the Agent Client Protocol to it (ADR-0030). On that pipe the phone approves (session/request_permission), answers questions (_x.ai/ask_user_question), continues (session/prompt), queues a supplement for the next prompt and stops (session/cancel); the row carries ControlChannel.acp. The hooks above still fire inside the hosted process with the same sessionId and only corroborate; the --approval gate says nothing for a hosted session, so one request never raises two cards. Availability = grok on the PATH and ~/.grok/auth.json present. Grok's own [ui] permission_mode decides how often a hosted session asks — vibebuddy does not change it.

Cursor (~/.cursor/hooks.json)

vibebuddyd hooks install --agent cursor              # merge vibebuddy's entries in
vibebuddyd hooks install --agent cursor --approval   # + the blocking phone-approval gate
vibebuddyd hooks uninstall --agent cursor            # remove only our entries

One user-level file serves both Cursor surfaces — the IDE's Agent panel and the cursor-agent CLI — and it is shared with whatever hooks you wrote yourself, so the installer merges: it only ever adds, replaces or removes entries whose command is one of vibebuddy's scripts, and a foreign entry keeps its place and its options (failClosed included). Cursor watches the file and reloads on save.

Two details of Cursor's contract shape the wiring:

  • A user-level hook runs with ~/.cursor/ as its working directory, so every command is written as an absolute path.
  • preToolUse fires for every tool, shell commands and MCP calls included, before Cursor's own permission check. That makes it the single place for the blocking gate; wiring beforeShellExecution as well would raise two cards for one command.

Installed events (Cursor's own camelCase names):

Family Events What vibebuddy does with them
lifecycle sessionStart, sessionEnd open the session (model, workspace, transcript path), then drop it
turn beforeSubmitPrompt, stop working → done; stop.status tells completed / error / aborted apart, so an abort you asked for is an ending, not a crash
tool preToolUse, postToolUse, postToolUseFailure, afterFileEdit active tool (under canonical names) and the stuck cue
output afterAgentResponse the agent's last words become the row's line
context preCompact Cursor's real context-token and window figures
topology subagentStart, subagentStop child-agent rows under the parent conversation

Sessions are keyed on conversation_id — the composer id — which is also what names the agent transcript and Cursor's own database row, so the hooks, the transcript tailer and the composer store describe one session rather than three.

stop runs hooks/cursor-followup.sh, which forwards the ending and collects whatever the phone queued for a running turn ({"followup_message": …}). That is Cursor's documented auto-continuation and the only remote write it offers: Cursor has no interrupt and no mid-turn steer, so vibebuddy queues a supplement and Cursor picks it up the moment the turn ends. A finished chat is continued the other way, with cursor-agent --resume <composer id> in a terminal.

--approval routes preToolUse through hooks/approval-hook.sh cursor, which answers Cursor's own {"permission": "allow" | "deny"} contract. The same gate carries Cursor's AskQuestion tool, so a question the agent asks becomes a card the phone can answer — delivered back as agent_message, because Cursor has no contract for returning a tool result from a hook. A timeout prints nothing, which Cursor reads as no opinion, and it asks in its own UI. Nothing here sets failClosed, so no vibebuddy hook can ever block Cursor.

The Cursor CLI does not yet send every event the IDE does (beforeSubmitPrompt and afterAgentResponse among them). The agent-transcript tailer covers the gap — and covers Cursor entirely when no hooks are installed at all.

Terminal capture (for jump-to-terminal)

Jump-to-terminal needs to know which terminal each session runs in. A second hook, hooks/capture-terminal.sh, POSTs to /terminal at three levels of precision: {tmux, tmux_pane} for the multiplexer pane, {tty, iterm_session_id, wezterm_pane, kitty_window_id, kitty_listen_on, ghostty_terminal_id} for the window/tab/split of one emulator, and {term_program, host_bundle_id, host_pid, cwd} for the app. host_bundle_id is the bundle identifier of the nearest GUI ancestor process, so a session inside an embedded terminal — the Claude desktop app, Cursor, Zed, a JetBrains IDE — is still reachable even though it exports no TERM_PROGRAM at all. Background-only ancestors (LSBackgroundOnly or LSUIElement in their Info.plist) are stepped over, because such a bundle id can never be activated: the Claude Code CLI is itself one of these wrapper .apps, nested under the Claude desktop app that actually owns the window. Empty values are omitted, and the Mac reads an empty string as absence. Run the script with --print to see what it would send. The Claude, Codex and Grok installs all wire it to both SessionStart and UserPromptSubmit: SessionStart catches new sessions, and UserPromptSubmit re-captures so a session that missed SessionStart — e.g. the hook was added while the session was already open — self-heals on its next prompt. That re-capture is not idempotent (it skips the Ghostty AppleScript probe, which only answers while the surface is focused), so the Mac merges each ref into the stored one field by field — a later capture updates what it saw and never erases what it didn't see. Grok's event payload uses camelCase sessionId and spells its event name hookEventName; the script reads the payload's session_id first, then sessionId, and only then falls back to $GROK_HOOK_EVENT — every process grok spawned inherits that variable, so it would otherwise mis-attribute a Claude session started from a shell inside grok. A session with no captured terminal can't be jumped to (the iOS button hides; the Mac button disables; a phone jump reports "no terminal"), and a session captured only at app level reports activatedApp — the right app comes forward but the user still finds the tab. Codex requires re-trusting hooks via /hooks after this change picks up the new capture group, same as any other hooks.json edit; Grok requires reloading hooks (/hooks → r, or a new session) before the new capture group takes effect, and its command must carry an argument — grok resolves a quoted, argument-less command as a literal path — so the capture hook is installed as "…/capture-terminal.sh" grok (and … claude on the Claude side, whose hooks grok imports through [compat.claude]).

Reversibility

Uninstall (Settings, or vibebuddyd hooks uninstall) removes exactly the entries whose command names one of vibebuddy's scripts — any path, old ones included — plus the commands recorded in the manifest, restores the status line, and leaves everything else in each file as it was.

Roadmap

  • Source routing + per-agent display (app understands all 8 sources)
  • Native install/uninstall that detects configured CLIs and writes/strips only vibebuddy's entries (HookInstaller; Settings and vibebuddyd hooks)
  • Per-CLI event-shape validation against the real tools
  • Copilot CLI read-only history (Wake database format; no lifecycle monitoring)
  • Cursor: hooks + agent transcript + composer store, remote approval and question answering, queued follow-ups, --resume continuation
  • Cursor Cloud Agents API (/v0/agents): needs a CURSOR_API_KEY of its own