Skip to content

Apply auth-expiry hook handling to the non-Claude agent hooks (follow-up to #183) #184

Description

@alexeyzimarev

Background

PR #183 fixed auth-expiry handling for the Claude Code hook (ClaudeHookCommand): when the token is expired and can't be refreshed, it no longer POSTs a request the server will 401, no longer exits non-zero (which Claude Code renders as a red "Stop hook error" banner every turn), and nudges the user to re-login at most once per session via a systemMessage JSON object.

That work added a reusable seam in src/Capacitor.Cli.Core/HttpClientExtensions.cs:

  • enum AuthStatus { Ok, NoAuthRequired, Expired, NotAuthenticated }
  • CreateClientWithAuthStatusAsync(...)(HttpClient, AuthStatus) — same as CreateAuthenticatedClientAsync but reports the outcome instead of swallowing it.

This issue is to carry the same handling to the other 7 agent hooks, which all still build their client with CreateAuthenticatedClientAsync() and POST blindly.

The shared problem

All non-Claude hook commands under src/Capacitor.Cli/Commands/ build the client via CreateAuthenticatedClientAsync() and, on a non-success response, write [kcap] <agent>-hook {endpoint}: HTTP {status} to stderr. When auth has lapsed this means: (a) a guaranteed-to-401 POST is still sent, and (b) a stderr line is logged on every affected event. How visible (b) is depends on each agent's hook system.

Goal (per hook)

  1. Switch the client build to CreateClientWithAuthStatusAsync.
  2. When status is Expired / NotAuthenticated: skip the POST (it will 401) and return the agent's clean/no-op exit code.
  3. Surface a re-login nudge at most once per session — on the session-start-equivalent event only, never on per-turn events — using that agent's own user-facing channel if one exists (see caveat).

Per-agent checklist (line numbers approximate)

  • CodexHookCommand.csPostHookAsync builds client at ~L321; non-success → stderr + return 1 (~L326). Session event: SessionStart/hooks/session-start/codex. Stop is not POSTed. Stdout is a JSON decision channel ({"continue":true}) — investigate whether Codex supports a user-message field.
  • GeminiHookCommand.cs — client at ~L240; non-success → stderr + return 1 (~L246). Session event: SessionStart/hooks/session-start/gemini; per-turn: Notification. Stdout is "a JSON decision channel; this dispatcher emits nothing" — likely no user-notice channel.
  • CopilotHookCommand.cs — client at ~L326; non-success → stderr + return 1 (~L332). Routes off --event; session event sessionStart/hooks/session-start/copilot; per-turn notification / agentStop. "Copilot treats hook stdout as optional… emits nothing."
  • PiHookCommand.cs — client at ~L208; non-success → stderr + return 1 (~L214). Routes off --event; session-start/hooks/session-start/pi. Fail-open contract ("a kcap/server problem must never disrupt the pi session").
  • KiroHookCommand.cs — client at ~L156; non-success → stderr + return 1 (~L162), but Handle already returns 0 (fail-open, ~L137). agentSpawn/hooks/session-start/kiro, fires on every prompt with the same session id. Emits nothing on stdout. Lower priority — already exits 0; the win is skipping the doomed POST + not logging the stderr line.
  • OpenCodeHookCommand.cs — client at ~L119; non-success → stderr + return 1 (~L125), but Handle already returns 0 (fail-open, ~L107). Only handles session-start/hooks/session-start/opencode. Same lower-priority note as Kiro.
  • CursorHookCommand.cs — different shape: TryPostHookAsync returns a bool and the caller is fail-open ("never crash Cursor"); no stderr error logged. Least-affected — main win is skipping the doomed POST. Session event: sessionStart via CursorHookEventMap.

Important caveats

  • The systemMessage notice channel is Claude-Code-specific. Do not copy it verbatim. Most of these agents emit nothing on stdout or treat stdout as a JSON decision/context channel — injecting a notice there would corrupt that channel or leak into the agent's context. For each agent, either find its real user-facing notice channel or, if none exists, soft-exit silently and rely on kcap status (already reports ✗ token expired (run: kcap login)) and the interactive CLI's own messaging. Surfacing the once-per-session nudge is best-effort and may be N/A for several agents.
  • Severity varies. Cursor, Kiro, and OpenCode are already fail-open at the process level, so they don't produce a per-turn error banner today — for them this is mostly hygiene (don't send a doomed request, don't log a misleading HTTP 401 line). Codex/Gemini/Copilot/Pi return 1; whether their host renders that as a user-visible error is per-agent and worth confirming.
  • Consider factoring the shared decision ("given AuthStatus + whether this is the session-start event, should I POST / what to emit / what exit code") into a small helper so the 7 hooks don't each reimplement it.

Acceptance criteria

  • No agent hook sends a POST it knows will 401 due to expired/missing auth.
  • No agent hook produces a misleading per-turn HTTP 401 stderr line (or error banner) solely because auth lapsed.
  • Where the agent has a user-facing notice channel, a re-login nudge appears at most once per session and never enters the agent's model/decision channel.
  • Behaviour is a no-op for the None auth provider and unchanged when authenticated.
  • dotnet publish -c Release shows no IL3050/IL2026 warnings; unit tests pass.

Reference

Template: PR #183 (ClaudeHookCommand.cs skip-and-exit-0 + systemMessage, and HttpClientExtensions.CreateClientWithAuthStatusAsync). Related: #182 (proactive daemon token refresh).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestquality of lifeImprovement to user experience

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions