Files
hapi/AGENTS.md
T
wushenghua f0ea0af94a docs(deploy): record the TCC cdhash trap and how to re-grant
A fixed path plus a pinned identity does not by itself keep TCC grants valid:
each grant stores a code requirement, and a grant created while the binary was
ad-hoc signed stays pinned to that build's cdhash. Replacing the binary at the
fixed path then matches nothing, and since a record exists macOS never prompts
again - it denies silently. Processes already running keep the cached allow
until macOS re-evaluates them, then lose ~/Documents mid-life; new processes are
unaffected. That is exactly how long-running sessions stopped answering with
"OpenCode service failure" / "getcwd: Operation not permitted" while fresh
sessions on the same machine worked.

AGENTS.md gets the short version; docs/local-deployment.md gets the csreq check
(identity-based vs cdhash), the normal re-grant through System Settings, and the
fallback used on mini on 2026-09-17 (delete the stale row, restart tccd, insert a
record copied from a machine with the same signing certificate), including the
row shape and the other two services that rot with it.
2026-09-18 01:51:01 +08:00

14 KiB

AGENTS.md

Work style: telegraph; noun-phrases ok; drop grammar;

Short guide for AI agents in this repo. Prefer progressive loading: start with the root README, then package READMEs as needed.

What is HAPI?

Local-first platform for running AI coding agents (Claude Code, Codex, Gemini) with remote control via web/phone. CLI wraps agents and connects to hub; hub serves web app and handles real-time sync.

Repo layout

cli/             - CLI binary, agent wrappers, runner daemon
hub/             - HTTP API + Socket.IO + SSE + Telegram bot
web/             - React PWA for remote control
ios/             - Native SwiftUI app (in development)
android/         - Native Kotlin Compose app (in development)
shared/          - Common types, schemas, utilities
shared/fixtures/ - Golden chat fixtures, generated from web pipeline (never hand-edit)
docs/            - VitePress documentation site
website/         - Marketing site

Bun workspaces; shared consumed by cli, hub, web. ios/android outside workspaces (Xcode / Gradle toolchains).

Architecture overview

┌─────────┐  Socket.IO   ┌─────────┐   SSE/REST   ┌─────────┐
│   CLI   │ ──────────── │   Hub   │ ──────────── │   Web   │
│ (agent) │              │ (server)│              │  (PWA)  │
└─────────┘              └─────────┘              └─────────┘
     │                        │                        │
     ├─ Wraps Claude/Codex    ├─ SQLite persistence   ├─ TanStack Query
     ├─ Socket.IO client      ├─ Session cache        ├─ SSE for updates
     └─ RPC handlers          ├─ RPC gateway          └─ assistant-ui
                              └─ Telegram bot

Data flow:

  1. CLI spawns agent (claude/codex/gemini), connects to hub via Socket.IO
  2. Agent events → CLI → hub (socket message event) → DB + SSE broadcast
  3. Web subscribes to SSE /api/events, receives live updates
  4. User actions → Web → hub REST API → RPC to CLI → agent

Reference docs

  • README.md - User overview, quick start
  • cli/README.md - CLI commands, config, runner
  • hub/README.md - Hub config, HTTP API, Socket.IO events
  • web/README.md - Routes, components, hooks
  • docs/guide/ - User guides (installation, how-it-works, FAQ)

Shared rules

  • No backward compatibility: breaking old formats freely
  • Prioritize Pragmatism, and Avoid Overengineering.
  • Write necessary tests ONLY.
  • TypeScript strict; no untyped code
  • Bun workspaces; run bun commands from repo root
  • Path alias @/* maps to ./src/* per package
  • Prefer 4-space indentation
  • Zod for runtime validation (schemas in shared/src/schemas.ts)

Common commands (repo root)

bun typecheck           # All packages
bun run test            # cli + hub + web + shared tests
bun run dev             # hub + web concurrently
bun run build:single-exe # All-in-one binary
bun run gen:fixtures    # Regenerate shared/fixtures/ from web pipeline
cd android && ./gradlew :core:protocol:test  # Android protocol conformance

Local binary deployment

Deploy with scripts/deploy-local.sh [tag] after bun run build:single-exe; remote Macs: scripts/deploy-remote.sh <ssh-target> [tag].

The binary always installs to the fixed path ~/.hapi/bin/hapi (a real file, no versioned filenames). Fixed path is deliberate: macOS TCC keys permission grants (Documents, media library, ...) to the executable path, so one stable path means the user grants access once and macOS remembers it across deploys. Versioned filenames re-prompted for every build - do not reintroduce them.

The fixed path has one hazard: the kernel caches the Mach-O signature per path, so overwriting it can kill new processes with OS_REASON_CODESIGNING / embedded signature doesn't match attached signature (often exit 137). The deploy scripts handle it:

  1. back up the installed binary to ~/.hapi/bin/backups/ (newest HAPI_KEEP_BACKUPS kept, default 2; never exec from the backup dir),
  2. install the new file with a fresh mtime so the path-keyed signature cache re-reads it,
  3. prove it execs repeatedly (hapi --version x3) and codesign --verify,
  4. restart the hub (local) / kickstart the launchd job (remote) and verify (/health locally; runner state + exec path on the remote),
  5. on any failure restore the backup onto the fixed path and restart.

Never leave an unverified binary installed. Never ship an ad-hoc signature (--sign -): ad-hoc signatures have no stable identity, so macOS TCC treats every build as a new app and re-asks each protected permission. Pin one Apple Development identity instead: SHA-1 in ~/.hapi/signing-identity, override via HAPI_SIGN_IDENTITY, signed identifier run.hapi.cli.

Pinning the identity is necessary but not sufficient: each TCC grant stores a code requirement, and a grant created while the binary was ad-hoc signed stays pinned to that build's cdhash. Replacing the binary at the fixed path then matches nothing - and since a record exists, macOS never prompts again, it denies silently. Long-running processes started before the swap keep their cached allow until macOS re-evaluates them, then lose ~/Documents access mid-life (symptom: sessions started hours ago stop answering with OpenCode service failure / getcwd: Operation not permitted, while new sessions on the same machine work). Check csreq for the hapi row in the user TCC database (want identifier "run.hapi.cli" and anchor apple generic ...; cdhash H"..." means re-grant) and re-grant once per machine after changing the signing identity - full procedure and fallback in docs/local-deployment.md.

The runner must be refreshed on every deploy (hapi runner start replaces the stale one; running sessions survive). Compiled binaries never self-update: the runner heartbeat compares the mtime of its own resolved exec path, which is fixed for the life of the process. A stale runner keeps old machine RPCs and capability flags, so hub features can fail with "restart the runner" errors.

Remote Macs with the same layout: scripts/deploy-remote.sh <ssh-target> [tag] (signs locally, uploads next to the fixed path, installs by move, verifies, then kickstarts the launchd job; label defaults to com.hapi.runner, override with HAPI_REMOTE_LAUNCHD_LABEL).

Agent sessions must not run recursive $HOME sweeps (find ~, du -sh ~) without pruning TCC-protected folders (~/Music, ~/Pictures, ~/Movies, ~/Library): a walk into ~/Music/Music/Media.localized raises a spurious Apple Music prompt attributed to the hapi binary.

After deploy: curl -fsS http://127.0.0.1:3006/health, ~/.hapi/bin/hapi --version, hapi runner status. If health fails, restore the backup from ~/.hapi/bin/backups/ before doing anything else.

docs/local-deployment.md contains the same rationale and rollback checklist.

iOS tests run in CI (ios.yml: macOS swift test); no local Xcode/Swift toolchain assumed.

Key source dirs

CLI (cli/src/)

  • api/ - Hub connection (Socket.IO client, auth)
  • claude/ - Claude Code integration (wrapper, hooks)
  • codex/ - Codex mode integration
  • agent/ - Multi-agent support (Gemini via ACP)
  • runner/ - Background daemon for remote spawn
  • commands/ - CLI subcommands (auth, runner, doctor)
  • modules/ - Tool implementations (ripgrep, difftastic, git)
  • ui/ - Terminal UI (Ink components)

Hub (hub/src/)

  • web/routes/ - REST API endpoints
  • socket/ - Socket.IO setup
  • socket/handlers/cli/ - CLI event handlers (session, terminal, machine, RPC)
  • sync/ - Core logic (sessionCache, messageService, rpcGateway)
  • store/ - SQLite persistence (better-sqlite3)
  • sse/ - Server-Sent Events manager
  • telegram/ - Bot commands, callbacks
  • notifications/ - Push (VAPID) and Telegram notifications
  • config/ - Settings loading, token generation
  • visibility/ - Client visibility tracking

Web (web/src/)

  • routes/ - TanStack Router pages
  • routes/sessions/ - Session views (chat, files, terminal)
  • components/ - Reusable UI (SessionList, SessionChat, NewSession/)
  • hooks/queries/ - TanStack Query hooks
  • hooks/mutations/ - Mutation hooks
  • hooks/useSSE.ts - SSE subscription
  • api/client.ts - API client wrapper

Shared (shared/src/)

  • types.ts - Core types (Session, Message, Machine)
  • schemas.ts - Zod schemas for validation
  • socket.ts - Socket.IO event types
  • messages.ts - Message parsing utilities
  • modes.ts - Permission/model mode definitions

iOS (ios/)

  • Packages/HapiKit/ - local SPM package: HapiProtocol (wire models + chat pipeline, fixtures-verified), HapiClient (API/auth/SSE/stores)
  • Hapi/ + Hapi.xcodeproj - thin SwiftUI app target

Android (android/)

  • :core:protocol - pure JVM wire types + chat pipeline (fixtures-verified)
  • :core:data - transport (OkHttp/SSE), auth, stores
  • :app - Compose UI, navigation, deep links, FCM

Protocol conformance (native apps)

  • shared/fixtures/** machine-generated from the web chat pipeline (source of truth). NEVER hand-edit; edit web/scripts/fixtures/cases/ + regenerate.
  • Changing web/src/chat/**, web/src/lib/message-window-store.ts, or web/src/lib/sessionPatch.ts: run bun run gen:fixtures, commit the diff. CI enforces (.github/workflows/fixtures.yml); fixture diffs auto-trigger iOS/Android conformance suites (ios.yml/android.yml).
  • Native client contract docs: docs/api/client-contract/ (auth, rest, sse, pagination, messages, errors).
  • Tracks: ios/ (SwiftUI, iOS 17+) + android/ (Kotlin Compose, minSdk 26) — independent codebases, share only contract + fixtures. Plan: ~/.claude/plans/web-pwa-abundant-yeti.md.

Pre-push self-review (agents)

Before commit/push/PR: use the pre-push-review skill (~/.cursor/skills/pre-push-review/).

  1. Mechanical: bun typecheck && bun run test (matches .github/workflows/test.yml)
  2. Logic: skim git diff origin/main...HEAD; apply .github/prompts/codex-pr-review.md as a local Major checklist (no Codex required)
  3. Style: optional

Testing

  • Test framework: Vitest (via bun run test)
  • Test files: *.test.ts next to source
  • Run: bun run test (from root) or bun run test (from package)
  • Hub tests: hub/src/**/*.test.ts
  • CLI tests: cli/src/**/*.test.ts
  • Web tests: web/src/**/*.test.{ts,tsx} (fixtures self-check: web/src/chat/fixtures.test.ts)

Common tasks

Task Key files
Add CLI command cli/src/commands/, cli/src/index.ts
Add API endpoint hub/src/web/routes/, register in hub/src/web/index.ts
Add Socket.IO event hub/src/socket/handlers/cli/, shared/src/socket.ts
Add web route web/src/routes/, web/src/router.tsx
Add web component web/src/components/
Modify session logic hub/src/sync/sessionCache.ts, hub/src/sync/syncEngine.ts
Modify message handling hub/src/sync/messageService.ts
Add notification type hub/src/notifications/
Add shared type shared/src/types.ts, shared/src/schemas.ts

Important patterns

  • RPC: CLI registers handlers (rpc-register), hub routes requests via rpcGateway.ts
  • Versioned updates: CLI sends update-metadata/update-state with version; hub rejects stale
  • Session modes: local (terminal) vs remote (web-controlled); switchable mid-session
  • Permission modes: default, acceptEdits, auto, bypassPermissions, plan
  • Namespaces: Multi-user isolation via CLI_API_TOKEN:<namespace> suffix

Adding new web features — consider an FUE

When you ship a non-essential feature (the 20% of sessions, not the 80%), consider wrapping its affordance in the generic First-User-Experience primitive so existing users discover it without a giant always-visible UI block.

  • Hook: web/src/lib/use-fue.ts — useFue(featureId) returns { status, engage, dismiss }. Storage namespace hapi.fue.v1.<featureId> (one localStorage key per feature, isolated from any upstream onboarding flow).
  • Components: web/src/components/Fue.tsx — <FueDot> (small pulsing badge for the affordance) and <FueCallout> (portal-rendered popover with title/body + "Got it" affirmative-action dismiss).

Pattern (~10 lines around the affordance):

const fue = useFue('my-feature')
const buttonRef = useRef<HTMLButtonElement>(null)
return (
    <>
        <button ref={buttonRef} onClick={() => { fue.engage(); doThing() }}>
            <Icon />
            {fue.status !== 'acknowledged' ? <FueDot pulsing={fue.status === 'unseen'} /> : null}
        </button>
        {fue.status === 'engaging' ? (
            <FueCallout
                title={t('myFeature.fueTitle')}
                body={t('myFeature.fueBody')}
                onDismiss={fue.dismiss}
                anchorRef={buttonRef}
            />
        ) : null}
    </>
)

Rules:

  • Affirmative action only: there is no auto-timeout — user dismisses by clicking "Got it" (reading speed varies).
  • The FUE dot and any feature-specific badge (e.g. an entry counter) should be mutually exclusive: onboarding signal beats inventory signal until acknowledged.
  • Storage is opt-in per-feature; if upstream ships its own onboarding for a feature, just don't wrap that affordance.

Canonical example: scratchlist toggle in web/src/components/AssistantChat/ComposerButtons.tsx (ScratchlistToggleButton).

Critical Thinking

  1. Fix root cause (not band-aid).
  2. Unsure: read more code; if still stuck, ask w/ short options.
  3. Conflicts: call out; pick safer path.
  4. Unrecognized changes: assume other agent; keep going; focus your changes. If it causes issues, stop + ask user.