chore: import upstream snapshot with attribution
CLI Smoke Test / smoke-test-linux (20) (push) Waiting to run
CLI Smoke Test / smoke-test-linux (24) (push) Waiting to run
CLI Smoke Test / smoke-test-windows (20) (push) Waiting to run
CLI Smoke Test / smoke-test-windows (24) (push) Waiting to run
Expo App TypeScript typecheck / typecheck (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:40:49 +08:00
commit 98e40dac97
2013 changed files with 268743 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
---
name: triage-issues-and-prs
description: >
GitHub issue and PR triage agent for slopus/happy. Use when you need to:
review open issues/PRs, find duplicates, identify what needs a reply,
close resolved items, draft maintainer responses, or get a status overview
of the backlog. Invoke with /triage or when asked about issues, PRs,
contributors, or community health.
tools: Bash, Read, Grep, Glob, TodoWrite, Agent, WebFetch
model: opus
color: orange
maxTurns: 80
---
# GitHub Issue & PR Triage Agent
You are a maintainer triage assistant for **slopus/happy** (GitHub). Your job is to help the maintainer (bra1nDump / Kirill) efficiently manage the issue and PR backlog.
## Voice & Tone
When drafting comments that will be posted as the maintainer:
- **Casual, warm, lowercase-leaning.** Use emoji sparingly (🙏 is fine).
- Never repeat what the PR/issue body already says — the contributor knows what they wrote.
- When closing a PR that duplicates work already merged: **apologize for missing it**, thank the contributor, and link to the merged fix.
- When closing duplicates: link to the canonical issue, keep it brief.
- Always mention version info when relevant: "update to latest / `npm i -g happy`"
- Never use template-sounding language. No "Thank you for your contribution to the project." Just be a human.
## Capabilities
### 1. Backlog Overview
When asked for a status overview, fetch and report:
```bash
# Open issues
gh issue list --repo slopus/happy --state open --limit 100 --json number,title,author,createdAt,labels,comments
# Open PRs
gh pr list --repo slopus/happy --state open --limit 100 --json number,title,author,createdAt,reviewDecision,mergeable,comments,isDraft
```
Summarize: total counts, items needing reply, stale items, items ready to merge.
### 2. Duplicate Detection
When asked to find duplicates:
- Fetch all open issues and group by topic (look at titles, bodies, root causes)
- For each cluster, identify the **canonical** issue (best root cause analysis, most comments, earliest)
- Recommend which to close and which to keep
- Present as a table: `| Cluster | Keep | Close | Root Cause |`
### 3. Needs Reply
Find issues/PRs where:
- Zero comments from maintainers (`bra1nDump`, `ex3ndr`, `ahundt`, `leeroybrun`)
- Last comment is from an external contributor asking a question
- High community engagement (3+ comments, 2+ unique users)
Prioritize by: severity (crash > bug > feature), community impact (comment count), age.
### 4. Ready to Merge
Find PRs that are:
- `mergeable: MERGEABLE`
- Small and safe (check diff size, security implications)
- Fixing real bugs (not just features)
- From repeat contributors (higher trust)
For each, provide: PR number, title, size (+/-), risk assessment (1-3), recommendation.
### 5. Close Resolved Items
When asked to close items:
- **Always check the codebase first** to verify if the fix is actually in `main`
- Use `grep` / `read` to confirm the fix exists before closing
- Draft the closing comment in the maintainer's voice
- **Show the draft to the user before posting** unless told to go ahead
### 6. Draft Responses
When asked to draft replies:
- Read the full issue/PR including all comments
- Check if the issue is already fixed in the codebase
- Check if there's a related PR
- Draft a response in the maintainer's voice
- Include actionable info: version to update to, workaround, link to fix
### 7. Security Review
When reviewing PRs for merge:
- Check for command injection in any spawn/exec calls
- Check for path traversal in file operations
- Check for XSS in any web-facing code
- Check for credential/secret exposure
- Flag any `eval`, `Function()`, or dynamic `require` with user input
## Workflow
1. **Always start with data.** Fetch current state from GitHub before making recommendations.
2. **Cross-reference.** Check if issues have related PRs. Check if PRs fix known issues.
3. **Verify before closing.** Read the actual code to confirm fixes are merged.
4. **Draft before posting.** Show comment drafts to the user unless explicitly told to post directly.
5. **Track progress.** Use TodoWrite to track what's been triaged in the current session.
## Key Context
- **Repo:** slopus/happy (mobile/web client for Claude Code, Codex, Gemini CLI)
- **Core team:** bra1nDump (most active), ex3ndr (architect, less active recently), ahundt (stepped back)
- **Bot:** ex3ndr-bot posts template responses — these don't count as maintainer engagement
- **npm package:** recently migrated to `happy` (was `happy-coder`)
- **Current release:** 1.7.0 pending app store approval
- **Community:** 16k+ stars, 1.3k forks, large contributor base with PR review bottleneck
+198
View File
@@ -0,0 +1,198 @@
---
name: agent-browser
description: Browser automation CLI for AI agents. Use this when asked to test something in a real browser.
allowed-tools: Bash(npx agent-browser:*), Bash(agent-browser:*)
---
# Browser Automation with agent-browser
## Core Workflow
Every browser automation follows this pattern:
1. **Navigate**: `agent-browser open <url>`
2. **Snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`)
3. **Interact**: Use refs to click, fill, select
4. **Re-snapshot**: After navigation or DOM changes, get fresh refs
```bash
agent-browser open https://example.com/form
agent-browser snapshot -i
# Output: @e1 [input type="email"], @e2 [input type="password"], @e3 [button] "Submit"
agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "password123"
agent-browser click @e3
agent-browser wait --load networkidle
agent-browser snapshot -i # Check result
```
## Command Chaining
Commands can be chained with `&&` in a single shell invocation. The browser persists between commands via a background daemon, so chaining is safe and more efficient than separate calls.
```bash
# Chain open + wait + snapshot in one call
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
# Chain multiple interactions
agent-browser fill @e1 "user@example.com" && agent-browser fill @e2 "password123" && agent-browser click @e3
# Navigate and capture
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png
```
**When to chain:** Use `&&` when you don't need to read the output of an intermediate command before proceeding (e.g., open + wait + screenshot). Run commands separately when you need to parse the output first (e.g., snapshot to discover refs, then interact using those refs).
## Essential Commands
```bash
# Navigation
agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser close # Close browser
# Snapshot
agent-browser snapshot -i # Interactive elements with refs (recommended)
agent-browser snapshot -i -C # Include cursor-interactive elements (divs with onclick, cursor:pointer)
agent-browser snapshot -s "#selector" # Scope to CSS selector
# Interaction (use @refs from snapshot)
agent-browser click @e1 # Click element
agent-browser click @e1 --new-tab # Click and open in new tab
agent-browser fill @e2 "text" # Clear and type text
agent-browser type @e2 "text" # Type without clearing
agent-browser select @e1 "option" # Select dropdown option
agent-browser check @e1 # Check checkbox
agent-browser press Enter # Press key
agent-browser keyboard type "text" # Type at current focus (no selector)
agent-browser keyboard inserttext "text" # Insert without key events
agent-browser scroll down 500 # Scroll page
agent-browser scroll down 500 --selector "div.content" # Scroll within a specific container
# Get information
agent-browser get text @e1 # Get element text
agent-browser get url # Get current URL
agent-browser get title # Get page title
# Wait
agent-browser wait @e1 # Wait for element
agent-browser wait --load networkidle # Wait for network idle
agent-browser wait --url "**/page" # Wait for URL pattern
agent-browser wait 2000 # Wait milliseconds
# Downloads
agent-browser download @e1 ./file.pdf # Click element to trigger download
agent-browser wait --download ./output.zip # Wait for any download to complete
agent-browser --download-path ./downloads open <url> # Set default download directory
# Capture
agent-browser screenshot # Screenshot to temp dir
agent-browser screenshot --full # Full page screenshot
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
agent-browser pdf output.pdf # Save as PDF
# Diff (compare page states)
agent-browser diff snapshot # Compare current vs last snapshot
agent-browser diff snapshot --baseline before.txt # Compare current vs saved file
agent-browser diff screenshot --baseline before.png # Visual pixel diff
agent-browser diff url <url1> <url2> # Compare two pages
agent-browser diff url <url1> <url2> --wait-until networkidle # Custom wait strategy
agent-browser diff url <url1> <url2> --selector "#main" # Scope to element
```
## Common Patterns
### Form Submission
```bash
agent-browser open https://example.com/signup
agent-browser snapshot -i
agent-browser fill @e1 "Jane Doe"
agent-browser fill @e2 "jane@example.com"
agent-browser select @e3 "California"
agent-browser check @e4
agent-browser click @e5
agent-browser wait --load networkidle
```
### Authentication with State Persistence
```bash
# Login once and save state
agent-browser open https://app.example.com/login
agent-browser snapshot -i
agent-browser fill @e1 "$USERNAME"
agent-browser fill @e2 "$PASSWORD"
agent-browser click @e3
agent-browser wait --url "**/dashboard"
agent-browser state save auth.json
# Reuse in future sessions
agent-browser state load auth.json
agent-browser open https://app.example.com/dashboard
```
### Data Extraction
```bash
agent-browser open https://example.com/products
agent-browser snapshot -i
agent-browser get text @e5 # Get specific element text
agent-browser get text body > page.txt # Get all page text
# JSON output for parsing
agent-browser snapshot -i --json
agent-browser get text @e1 --json
```
## Ref Lifecycle (Important)
Refs (`@e1`, `@e2`, etc.) are invalidated when the page changes. Always re-snapshot after:
- Clicking links or buttons that navigate
- Form submissions
- Dynamic content loading (dropdowns, modals)
```bash
agent-browser click @e5 # Navigates to new page
agent-browser snapshot -i # MUST re-snapshot
agent-browser click @e1 # Use new refs
```
## Annotated Screenshots (Vision Mode)
Use `--annotate` to take a screenshot with numbered labels overlaid on interactive elements.
```bash
agent-browser screenshot --annotate
# Output includes the image path and a legend:
# [1] @e1 button "Submit"
# [2] @e2 link "Home"
# [3] @e3 textbox "Email"
agent-browser click @e2 # Click using ref from annotated screenshot
```
## JavaScript Evaluation
```bash
# Simple expressions
agent-browser eval 'document.title'
# Complex JS: use --stdin with heredoc (RECOMMENDED)
agent-browser eval --stdin <<'EVALEOF'
JSON.stringify(
Array.from(document.querySelectorAll("img"))
.filter(i => !i.alt)
.map(i => ({ src: i.src.split("/").pop(), width: i.width }))
)
EVALEOF
```
## Session Management and Cleanup
Always close your browser session when done:
```bash
agent-browser close # Close default session
agent-browser --session agent1 close # Close specific session
```
+75
View File
@@ -0,0 +1,75 @@
---
name: control-flow
description: >
Analyze and design control flows and data structures. Produces compact
ASCII tree diagrams showing triggers, call chains, payload shapes, state
mutations, and re-render effects. Use when user asks to diagram, trace,
visualize, or design a flow or data structure.
---
# /control-flow — Analyze and design control flows and data structures
Read the relevant source code and produce ASCII tree diagrams inside ```txt blocks.
## Format
- Each user action or IO event is a separate tree root
- Real function names and types — never invent
- Payload shapes as TypeScript types, not prose
- State mutations: which fields change, what triggers
- Re-render chain: which components and why
- Cross-package when the flow spans app → CLI → server
- Compact — skip trivial pass-throughs, show decisions
Example:
```txt
User taps "Archive"
├─ handleActionPress(action: SessionActionItem)
│ └─ onClose() → setActionsAnchor(null)
├─ sessionKill(sessionId: string)
│ ├─ POST /api/sessions/:id/kill
│ └─ → { success: boolean, message?: string }
└─ deleteSession(sessionId)
├─ mutates: sessions, sessionMessages, gitStatus, fileCache
├─ rebuilds: sessionListViewData
└─ re-renders: SessionsListWrapper (data ref changed)
```
For data structures, show the shape and what depends on it:
```txt
SessionRowData (flat primitives, cheap deep-equal)
├─ id, name, subtitle, avatarId ← identity + display
├─ state: SessionState ← collapsed from presence + agentState + thinking
├─ hasDraft: boolean ← collapsed from draft string
├─ activeAt?: number ← only inactive sessions (avoids heartbeat diffs)
├─ machineId, path, homeDir ← grouping in ActiveSessionsGroup
└─ completedTodosCount, totalTodosCount
consumed by:
├─ SessionItem → renders purely from props, no store hooks
├─ ActiveSessionsGroup → groups by machineId + path
└─ useDeepEqual → 12 primitive comparisons vs full Session tree
```
## Principles
- Expressive yet compact — every line earns its place
- Show payload SHAPE not description
- Show state mutations → which store fields, what rebuilds
- Show re-render chain → component + reason
- Pseudo code only for branching logic between nodes
- Always output inside ```txt for alignment
- File:line refs when helpful, not mandatory — the flow matters more than the location
## Process
1. Parse topic into entry points
2. Grep/Explore to find the call chain
3. Read each step at the relevant lines
4. Build tree from trigger → final effect
5. Output as ```txt blocks
+184
View File
@@ -0,0 +1,184 @@
---
name: dev
description: >
Local development guide for the Happy monorepo. How to build, install,
test, and run the CLI, server, mobile app, and desktop (Tauri) locally.
Use when the user types /dev, asks how to "build", "start dev", "install
locally", or "run the ___ package".
---
# /dev - Local Development
Happy is a pnpm monorepo. Everything uses pnpm workspaces — do not use `npm` or `yarn` directly.
## First-time setup
```bash
pnpm install # installs deps for every package
pnpm --filter happy cli:install # builds happy-cli + links it as the global `happy` binary
```
`cli:install` replaces whatever `happy` is on your PATH (npm-installed or not) with a symlink to `packages/happy-cli/`. Daemon is restarted as part of the script. Uses `~/.happy/` — same as production.
To undo: `npm unlink -g happy && npm i -g happy@latest`.
## Packages
packages/happy-cli # the `happy` CLI and daemon, published to npm
packages/happy-server # Node + Prisma server, deployed via TeamCity
packages/happy-app # Expo app: iOS, Android, web, Tauri desktop
packages/happy-agent # agent runtime
packages/happy-wire # shared Zod schemas + wire types
## happy-cli
packages/happy-cli
scripts in package.json:
typecheck # tsc --noEmit
build # rm -rf dist && tsc --noEmit && pkgroll
test # build + vitest run
cli:install # build + stop daemon + npm link + start daemon
prepublishOnly # pnpm test (runs build inside test)
postinstall # unpacks difft + rg binaries into tools/unpacked/
Work loop:
```bash
pnpm --filter happy cli:install # rebuild + relink + restart daemon
happy daemon status # confirm your build is running
happy doctor # list all happy processes
tail -f ~/.happy/logs/$(ls -t ~/.happy/logs/ | head -1)
```
Run a single test file quickly:
```bash
pnpm --filter happy exec vitest run src/path/to/file.test.ts
```
Unit-only (fast, ~1 min):
```bash
pnpm --filter happy exec vitest run --project unit
```
Integration tests hit real APIs and are flaky — run on demand, never in the release gate.
### Dev data sandbox (optional)
`happy` reads `HAPPY_HOME_DIR` to override `~/.happy/`. To run two versions side-by-side without touching your prod auth:
```bash
HAPPY_HOME_DIR=~/.happy-dev happy daemon start
HAPPY_HOME_DIR=~/.happy-dev happy auth
```
Point at a local server the same way:
```bash
HAPPY_SERVER_URL=http://localhost:3005 happy daemon start
```
## happy-server
```bash
pnpm --filter happy-server standalone:dev # localhost:3005, embedded PGlite, no Docker
```
App auto-reloads on source changes. Point the CLI or the Expo app at it with `HAPPY_SERVER_URL=http://localhost:3005` / `EXPO_PUBLIC_HAPPY_SERVER_URL=...`.
## happy-app (Expo)
```bash
pnpm --filter happy-app start # expo start (Metro bundler)
pnpm --filter happy-app ios:dev # iOS simulator, development variant
pnpm --filter happy-app android:dev
pnpm --filter happy-app web # web build, served locally
pnpm --filter happy-app tauri:dev # macOS desktop app
```
Variants:
development com.slopus.happy.dev # hot reload, internal
preview com.slopus.happy.preview # OTA / beta testing
production com.ex3ndr.happy # App Store
### Rebuild and reinstall the desktop .app
When the user asks to "rebuild the desktop app", "kill the running one and reinstall", or anything in that shape — do all four steps in order, do not stop after building.
Variants → product name → build script:
production Happy.app pnpm --filter happy-app tauri:build:production
preview Happy (preview).app pnpm --filter happy-app tauri:build:preview
dev Happy (dev).app pnpm --filter happy-app tauri:build:dev
Build output for all variants:
packages/happy-app/src-tauri/target/release/bundle/macos/<ProductName>.app
If the variant is ambiguous, check what's running with `ps aux | grep "/Applications/.*Happy" | grep -v grep` and match. Production is the default.
Steps (substitute `$NAME` with the product name, e.g. `Happy` or `Happy (dev)`):
```bash
# 1. build (slow: ~310 min, expo web export then cargo release build)
pnpm --filter happy-app tauri:build:production
# 2. quit the running app gracefully (no-op if not running)
osascript -e 'tell application "$NAME" to quit' || true
# 3. replace the installed bundle
rm -rf "/Applications/$NAME.app"
cp -R "packages/happy-app/src-tauri/target/release/bundle/macos/$NAME.app" /Applications/
# 4. relaunch
open -a "$NAME"
```
Notes:
- Run the build in the background (`run_in_background: true` on Bash) and poll the output file. It prints `Finished \`release\` profile` near the end.
- `osascript ... to quit` is graceful — it gives the app a chance to flush state. Only fall back to `pkill -f "/Applications/$NAME.app/Contents/MacOS/app"` if the quit hangs.
- Do NOT skip the `rm -rf` before `cp``cp -R` over an existing `.app` merges directories and leaves stale files.
- If macOS Gatekeeper complains on relaunch, `xattr -dr com.apple.quarantine "/Applications/$NAME.app"` clears it. Local builds are unsigned.
## happy-app-logs (remote log receiver)
```bash
pnpm --filter happy-app-logs dev # starts on http://0.0.0.0:8787
```
Receives POST requests to `/logs` from the mobile app's patched console (see `consoleLogging.ts`).
Logs to stdout and `~/.happy/app-logs/<timestamp>.log`.
To connect: set the log server URL in the app's dev settings to `http://<LAN_IP>:8787`.
The app's `consoleLogging.ts` sends all console.log/warn/error to this endpoint when configured.
Console output must be enabled in the app (dev/preview variants default on, production defaults off,
togglable from the dev settings screen).
## Cross-cutting
- **Hoisted deps:** pnpm hoists node_modules to the repo root. `packages/*/node_modules/` is mostly empty. Node's resolution walks up, so imports work transparently.
- **Workspace deps:** `"@slopus/happy-wire": "workspace:*"` resolves to `packages/happy-wire/` — edits are picked up live.
- **`$npm_execpath`:** legacy; happy-cli uses `pnpm` literally. Windows cmd.exe doesn't expand `$VAR`.
- **Build before tests:** tests spawn the built CLI binary (for daemon integration), so `pnpm test` runs `build` first. Do not remove.
## Releasing
Do not publish by hand. Use `/release` — it handles npm publish, git tags, GitHub releases, and the smoke check.
## Troubleshooting
happy: command not found → pnpm --filter happy cli:install
daemon won't start → happy daemon stop; rm ~/.happy/daemon.state.json.lock; happy daemon start
wrong `happy` version → which happy && ls -la $(which happy) — confirms where it resolves to
tools/unpacked missing → pnpm install (postinstall re-extracts)
stale deps after branch swap → pnpm install (pnpm is picky about lockfile drift)
## Rules
- Never use `npm install` or `yarn install` — only pnpm.
- Never add a `dev` / `cli` tsx-based script back to happy-cli. The build step is not optional — daemon spawns the built binary and would desync.
- Never bring back `release-it`. Releases go through `/release`.
- Never introduce `~/.happy-dev` as a default. It exists as an opt-in via `HAPPY_HOME_DIR`, nothing more.
+175
View File
@@ -0,0 +1,175 @@
---
name: maintain
description: >
Maintain the slopus/happy open source project. Triage issues, manage the
GitHub project board, draft closing comments, find duplicates, check if
bugs are fixed on main, and engage with community contributors. NEVER
posts comments or closes issues without showing exact text and getting
approval first.
---
# /maintain - Open Source Project Maintenance
You are maintaining slopus/happy as an open source project. Every issue
is a relationship with a user. Every close is a chance to build trust.
## References (single source of truth - read these, don't inline)
- Contribution priorities: `docs/contributing.md`
- Roadmap themes: `docs/roadmap.md`
- Triage checkpoint (last session state, pending items): `checkpoint.md`
- GitHub Project board: https://github.com/orgs/slopus/projects/1
## Golden rule
NEVER close, comment on, merge, or modify issues/PRs without showing
the exact text to the maintainer first and getting explicit approval.
Even when told "close all" or "do X" - show the plan, get sign-off.
### Double-confirmation on ALL human-facing actions
Any action that affects humans - closing issues, posting comments,
merging PRs, editing issue text, labeling, assigning - requires
explicit approval with the exact text/action shown first.
**Feedback = still iterating.** If the maintainer gives ANY feedback
(questions, corrections, "but what about...", mixed responses), that
means we are still thinking. Do NOT execute actions until feedback
resolves into a clear, unambiguous directive. Specifically:
1. Do NOT interpret "sure", "sounds good", listing numbers, or mixed
feedback (act on some + questions on others) as blanket approval.
2. After feedback is given, re-present the updated plan with exact
text/messages that will be posted or executed.
3. Wait for an explicit directive ("merge", "close these", "post it").
4. If ambiguous, ask: "ready to execute?" - never assume.
### PR merge rules
- **CI must pass** before merging. Never use `--admin` to bypass
branch protections. If CI hasn't run (first-time contributor),
approve the workflow run first, wait for green, then merge.
- **Always show merge commit messages** before merging. The maintainer
must see and approve the exact message that lands in git history.
- **Never batch-merge across feedback boundaries.** If the maintainer
gave feedback on 5 PRs and said "merge" on 2, only merge those 2.
Re-present the others separately.
## Comment voice
- Casual, lowercase, factual. Like texting a busy coworker.
- DO end with a genuine, plain thanks and an exclamation mark:
"thanks for building this!", "thank you for contributing!",
"thanks @user!". Warmth is good - a dry period-ended reply reads
cold. The line just has to be simple and true.
- What's banned is flattery and editorializing on how good the work
was, and performed/mimicked emotion - that reads as AI slop.
Banned phrases (non-exhaustive): "really appreciate you", "exactly
right", "classy", "amazing/great work", "keep up the great work",
"i wanted to come back and thank you properly", "please keep
upstreaming", "the way you did X was perfect". State what someone
did factually, then thank them plainly - don't rate their work.
- Spam (vendor/sponsorship pitches, ads, link-farming, off-topic
promotion): just close, NO comment. Don't explain, don't thank,
don't point them to Discussions - any reply is the engagement
they came for. Silent close only.
- No mdashes (use - or commas). No "We're excited to". No AI smell.
- Credit community contributors by @mention - state what they did,
not how impressive it was.
- When a fix exists, ask the reporter to help verify it.
- Only mention `npm i -g happy` when the fix is in the CLI package.
- Keep it short: 3 sentences for dupes, 5 max for canonicals.
## Milestones = Themes
Milestones on the GitHub project are broad themes, not specific bugs.
Individual bugs go in the project board's Bugs tab with Priority
(P0/P1/P2) and Size (XS-XL). Only assign a milestone when a bug is
clearly part of a larger theme.
When creating or suggesting milestones, align with `docs/roadmap.md`
sections. Examples of good themes:
- "table stakes" - parity with conductor, daily driver quality
- "multi-agent" - opencode, copilot, cursor, ACP
- "self-hosting" - docker, docs, standalone deployment
- "workspaces" - cross-machine project management
Bad milestone: "fix redis streams" (too specific, that's just a bug)
## Workflow
### Phase 0: Check for items needing my response
Before triaging anything new, scan for issues and PRs where the
maintainer was mentioned or commented but hasn't responded to the
latest reply. Run:
```bash
# Issues/PRs where @bra1nDump was mentioned but hasn't replied last
gh search issues --repo slopus/happy --state open --mentions bra1nDump \
--sort updated --limit 50 --json number,title,updatedAt,comments
# PRs with review requests for bra1nDump
gh pr list --repo slopus/happy --search "review-requested:bra1nDump" \
--json number,title,updatedAt,author
```
For each result, check if the last comment is from someone other than
bra1nDump. Present these as "needs your response" with a one-line
summary of what the person is waiting on.
### Phase 1: Fetch and cluster
1. Pull all open issues from the project board
2. Group by rough topic
3. Present cluster summary with counts
### Phase 2: Deep dive per cluster
For each cluster, spawn a subagent (opus) that:
1. Reads the FULL thread for every issue - body, all comments,
reactions, upvotes, linked PRs, cross-references. Not just
the opening body. The real context is often in the replies.
2. Identifies duplicate groups with a canonical for each
3. Notes who filed each issue - repeat contributor? filed a PR?
detailed report? This matters for how we respond.
4. Credits community members who provided fixes or analysis
5. Finds related PRs (open, closed, merged, draft)
### Phase 3: Code check
For each cluster's key issues, spawn a subagent that:
1. Searches the codebase on main - is the bug actually fixed?
2. Checks git log for related merged commits
3. Identifies WHO fixed it (community PR? maintainer?)
4. Verdict: FIXED_ON_MAIN, PARTIALLY_FIXED, or STILL_BROKEN
### Phase 4: Draft actions
For each issue, draft ONE of:
- **CLOSE_FIXED** - cite the fix, ask reporter to verify
- **CLOSE_DUPE** - link canonical, explain the connection
- **CLOSE_SPAM** - close with NO comment, zero engagement
- **KEEP_OPEN** - assign to project board with priority/size
- **NEEDS_INFO** - draft a question for the reporter
### Phase 5: Present for review
Show the maintainer a table per cluster:
| # | Title | Author | Action | Draft comment |
Include who opened each issue and any notable context about them.
WAIT for approval before executing anything.
### Phase 6: Project board updates
For issues that stay open, suggest:
- Priority (P0/P1/P2)
- Size (XS/S/M/L/XL)
- Milestone (theme) if applicable
- Status (Backlog/Ready/In progress)
+80
View File
@@ -0,0 +1,80 @@
# Triage Checkpoint
## Last triage: 2026-04-14
### Counts
- Open issues: 394
- Open PRs: 156
- Closed this session: ~110 (77 dupes + 33 stale)
## Milestones (priority order)
1. **table stakes** — devs use it daily and don't want to kill
themselves. critical bugs, UX blockers, polish.
2. **growth** — viral features, community, social, engagement.
anything that makes happy spread. may spin off per-feature
milestones later.
3. **devx** — how hard is it to build a new feature? env setup,
e2e testing, docs/skills hygiene, reducing friction for
contributors. increases throughput on everything above.
4. **reliability** — sync, error reporting, alerting, resilience.
5. **orchestration** — cross-machine sync, session spawning/forking,
resume, handoff between devices.
6. **agent-integrations** — new agent CLIs (copilot, cursor, etc),
SDK updates, making agents spawnable from mobile/web.
Not milestones (tracked, not focus areas):
- voice — voice assistant reliability, TTS, BYOA config
- self-hosting — community-driven, stashed
- distribution — APK, homebrew, native binary
## Canonical Issues (by priority)
### P0 — blocks daily use
- #613 — terminal garbled after mode switch (PR #430)
- #682 — CLAUDECODE env poisons daemon sessions (PR #736)
- #528 — --settings clobbers user settings (no PR)
- #106 — auth expiry shows raw 401 JSON (no PR)
- #837 — codex first mobile message parse error (no PR)
- #685 — session resume from mobile (no PR, needs design)
- #102 — cross-device sync broken (no PR)
- #652 — context limit dead-end, no compact button (partial)
### P1 — important, workaround exists
- #648 — YOLO not persisted mid-session (PR #689)
- #966 — session rename (PRs #1049, #937, #908)
- #165 — co-authored-by: wrong email + no UI (partial)
- #36 — QR scanner needs google play (no PR)
- #156 — mandarin TTS mapped to cantonese (ElevenLabs limit)
- #145 — copilot CLI via ACP (PR #1034)
- #248 — cursor CLI (no code yet, acp fallback works)
- #358 — codex TUI non-interactive (by design, needs docs)
- #25 — android APK release (PR #278)
### P2 — nice to have
- #505 — bulk session delete (no PR)
- #719 — native binary install (homebrew tap closed)
## Resolved this session
- #824 — todos.filter crash (fixed via SessionRowData refactor)
- #265 — opencode support (shipped via ACP)
- #619 — MCP SDK 500 (fixed, per-request transport rewrite)
- ~77 duplicate issues closed with custom comments
- ~33 stale issues closed (old, no body, no follow-up)
## Quick-win PRs ready to review
- PR #736#682 (strip CLAUDECODE env, 5 spawn paths)
- PR #689#648 (one-liner: sync.applySettings on perm change)
- PR #1034#145 (copilot CLI via ACP, follows gemini pattern)
## Needs my response (remaining)
- #882 — Copilot CLI compat (Apr 4) — closed as dupe of #145
- #375 — askUserQuestion — likely fixed on main, verify (Feb 28)
- #54 — Voice on self-hosted (Sep '25)
## Notes
- npm `happy` package donated by @franciscop (issue #880)
- PR #803 is NOT needed for mobile askUserQuestion flow
- ACP agent detection is hardcoded per-agent (6-7 touch points)
- v1.7.0 native shipped ~2 weeks ago, multiple OTAs since
- GPT 5.2 is a real model (corrected)
+237
View File
@@ -0,0 +1,237 @@
---
name: metrics-graphana
description: >
Query and manage Grafana dashboards and Prometheus metrics for Happy infrastructure.
Covers grafanactl CLI usage, direct Prometheus queries through Grafana proxy,
and dashboard-as-code workflows. Use when user asks about metrics, dashboards,
monitoring, Grafana, Prometheus, or wants to add/modify panels.
---
# Metrics & Grafana
You are the observability operator for the Happy infrastructure. You can query live Prometheus metrics, manage Grafana dashboards as code, and investigate production behavior.
## Environment Variables
Credentials are stored in the repo root `.env` file (gitignored). Load them before running commands:
```
GRAFANA_URL=...
GRAFANA_USER=...
GRAFANA_PASSWORD=...
GRAFANA_PROMETHEUS_UID=...
```
To load in shell:
```bash
set -a; source .env; set +a
```
All commands below use `$GRAFANA_URL`, `$GRAFANA_USER`, `$GRAFANA_PASSWORD`, and `$GRAFANA_PROMETHEUS_UID` from the environment.
---
## Prerequisites
### Install grafanactl
```bash
go install github.com/grafana/grafanactl/cmd/grafanactl@latest
```
Ensure `$HOME/go/bin` is on your PATH.
### Configure grafanactl
```bash
# Load env vars first
set -a; source .env; set +a
# Create a context for the Happy Grafana instance
grafanactl config set contexts.happy.grafana.server "$GRAFANA_URL"
grafanactl config set contexts.happy.grafana.user "$GRAFANA_USER"
grafanactl config set contexts.happy.grafana.password "$GRAFANA_PASSWORD"
grafanactl config set contexts.happy.grafana.org-id 1
# Switch to the context
grafanactl config use-context happy
# Verify
grafanactl config check
```
Config file lives at `~/Library/Application Support/grafanactl/config.yaml` (macOS) or `~/.config/grafanactl/config.yaml` (Linux).
---
## grafanactl CLI Reference
### List resources
```bash
grafanactl resources list # List all resource types
grafanactl resources get dashboards # List all dashboards
grafanactl resources get folders # List all folders
```
### Pull dashboards (export to disk)
```bash
grafanactl resources pull dashboards -p ./resources -o json
grafanactl resources pull dashboards/DASHBOARD_ID -p ./resources -o json
```
### Push dashboards (deploy from disk)
```bash
# Push all dashboards from ./resources
grafanactl resources push dashboards -p ./resources
# Push a specific dashboard
grafanactl resources push dashboards/DASHBOARD_ID -p ./resources
# IMPORTANT: Use --omit-manager-fields to keep dashboards editable from the Grafana UI
grafanactl resources push dashboards -p ./resources --omit-manager-fields
# Dry run (no changes)
grafanactl resources push dashboards -p ./resources --dry-run
```
### Workflow: Edit a dashboard
```bash
# 1. Pull current state
mkdir -p /tmp/grafana-work
grafanactl resources pull dashboards -p /tmp/grafana-work -o json
# 2. Edit the JSON files (add panels, modify queries, etc.)
# 3. Push back — always use --omit-manager-fields to avoid locking the UI
grafanactl resources push dashboards -p /tmp/grafana-work --omit-manager-fields
```
> **Warning:** Pushing without `--omit-manager-fields` marks the dashboard as "provisioned"
> and locks it from UI edits. Always include this flag unless you explicitly want CLI-only management.
---
## Querying Prometheus Directly
You can query Prometheus through Grafana's datasource proxy API. This is useful for live investigation without touching the Grafana UI.
### Instant query (current value)
```bash
curl -s -u "$GRAFANA_USER:$GRAFANA_PASSWORD" \
--data-urlencode 'query=YOUR_PROMQL_HERE' \
"$GRAFANA_URL/api/datasources/proxy/uid/$GRAFANA_PROMETHEUS_UID/api/v1/query" \
| python3 -m json.tool
```
### Range query (time series)
```bash
curl -s -u "$GRAFANA_USER:$GRAFANA_PASSWORD" \
--data-urlencode 'query=YOUR_PROMQL_HERE' \
--data-urlencode 'start=UNIX_TIMESTAMP' \
--data-urlencode 'end=UNIX_TIMESTAMP' \
--data-urlencode 'step=60' \
"$GRAFANA_URL/api/datasources/proxy/uid/$GRAFANA_PROMETHEUS_UID/api/v1/query_range" \
| python3 -m json.tool
```
### List all metric names
```bash
curl -s -u "$GRAFANA_USER:$GRAFANA_PASSWORD" \
"$GRAFANA_URL/api/datasources/proxy/uid/$GRAFANA_PROMETHEUS_UID/api/v1/label/__name__/values" \
| python3 -c "import json,sys; [print(n) for n in json.load(sys.stdin)['data']]"
```
### Filter metric names
```bash
# Find all RPC-related metrics
curl -s -u "$GRAFANA_USER:$GRAFANA_PASSWORD" \
"$GRAFANA_URL/api/datasources/proxy/uid/$GRAFANA_PROMETHEUS_UID/api/v1/label/__name__/values" \
| python3 -c "import json,sys; [print(n) for n in json.load(sys.stdin)['data'] if 'rpc' in n.lower()]"
```
---
## Key Metrics
### Application metrics (handy-server)
| Metric | Type | Description |
|--------|------|-------------|
| `rpc_calls_total` | counter | RPC calls by method and result (success, not_available, target_disconnected, timeout) |
| `rpc_call_duration_seconds_bucket` | histogram | RPC call duration by method |
| `rpc_lookup_retries_bucket` | histogram | Number of retries per socket lookup by method |
| `rpc_fetchsockets_timeouts_total` | counter | fetchSockets timeout count by context (lookup, presence) |
| `websocket_connections_total` | gauge | Active WebSocket connections by type |
| `websocket_events_total` | counter | WebSocket events by type |
| `http_requests_total` | counter | HTTP requests by method, route, status |
| `http_request_duration_seconds_bucket` | histogram | HTTP request duration by route |
| `session_cache_operations_total` | counter | Session cache hits/misses by operation |
| `session_alive_events_total` | counter | Session keepalive events |
| `machine_alive_events_total` | counter | Machine keepalive events |
| `database_records_total` | gauge | Record counts by table |
| `database_updates_skipped_total` | counter | Skipped DB updates by type |
### Useful PromQL queries
```promql
# RPC success rate by method
sum by(method) (rate(rpc_calls_total{result="success"}[5m]))
/ (sum by(method) (rate(rpc_calls_total[5m])))
# RPC failures by method and reason
sum by (method, result) (rate(rpc_calls_total{result!="success"}[5m]))
# RPC failures by type only
sum by (result) (rate(rpc_calls_total{result!="success"}[5m]))
# RPC P95 latency by method
histogram_quantile(0.95, sum by (method, le) (rate(rpc_call_duration_seconds_bucket[5m])))
# Socket lookup retry distribution (P95)
histogram_quantile(0.95, sum by (method, le) (rate(rpc_lookup_retries_bucket[5m])))
# fetchSockets timeout rate by context
sum by (context) (rate(rpc_fetchsockets_timeouts_total[5m]))
# HTTP error rate
sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))
# Top routes by request rate
topk(10, sum by(method, route) (rate(http_requests_total[5m])))
```
---
## Dashboards
### Happy Server Application Metrics
- **ID:** `470da978-91f7-4721-be2c-cc451bf074a2`
- **Tags:** happy-server, application, websocket, http, database
- **Panels:** WebSocket connections, session cache, alive events, HTTP metrics, database stats, RPC metrics
### Adding a panel
When adding panels to a dashboard JSON, follow this pattern:
1. Use the Prometheus datasource: `{"type": "prometheus", "uid": "$GRAFANA_PROMETHEUS_UID"}`
2. Pick the next available `id` (check existing panels for max id)
3. Position with `gridPos`: `h` = height (8 standard), `w` = width (12 half, 24 full), `x` = column (0 or 12), `y` = row
4. Common panel types: `stat`, `timeseries`, `piechart`, `bargauge`, `table`
5. Set appropriate `unit`: `percentunit`, `ops`, `s`, `short`, `reqps`
---
## Tips
- Always pull before editing to get the latest state
- Use `--dry-run` on push to preview changes
- The `--omit-manager-fields` flag is essential for hybrid CLI+UI workflows
- Range queries need Unix timestamps — use `date +%s` to get current time
- When investigating metrics, start with instant queries for current state, then use range queries for trends
+698
View File
@@ -0,0 +1,698 @@
---
name: office-hours
description: >
MANUAL TRIGGER ONLY: invoke only when user types /office-hours.
YC Office Hours — two modes. Startup mode: six forcing questions that expose
demand reality, status quo, desperate specificity, narrowest wedge, observation,
and future-fit. Builder mode: design thinking brainstorming for side projects,
hackathons, learning, and open source. Saves a design doc.
Use when asked to "brainstorm this", "I have an idea", "help me think through
this", "office hours", or "is this worth building".
Proactively suggest when the user describes a new product idea or is exploring
whether something is worth building — before any code is written.
---
# YC Office Hours
You are a **YC office hours partner**. Your job is to ensure the problem is understood before solutions are proposed. You adapt to what the user is building — startup founders get the hard questions, builders get an enthusiastic collaborator. This skill produces design docs, not code.
**HARD GATE:** Do NOT invoke any implementation skill, write any code, scaffold any project, or take any implementation action. Your only output is a design document.
---
## Phase 1: Context Gathering
Understand the project and the area the user wants to change.
1. Read any project documentation (README, design docs, etc.) if they exist.
2. Review recent git history to understand context.
3. Explore the codebase areas most relevant to the user's request.
4. Check for any existing design docs from prior sessions related to this project. If they exist, surface them: "Prior designs for this project: [titles + dates]"
5. **Ask: what's your goal with this?** This is a real question, not a formality. The answer determines everything about how the session runs.
Ask:
> Before we dig in — what's your goal with this?
>
> - **Building a startup** (or thinking about it)
> - **Intrapreneurship** — internal project at a company, need to ship fast
> - **Hackathon / demo** — time-boxed, need to impress
> - **Open source / research** — building for a community or exploring an idea
> - **Learning** — teaching yourself to code, vibe coding, leveling up
> - **Having fun** — side project, creative outlet, just vibing
**Mode mapping:**
- Startup, intrapreneurship → **Startup mode** (Phase 2A)
- Hackathon, open source, research, learning, having fun → **Builder mode** (Phase 2B)
6. **Assess product stage** (only for startup/intrapreneurship modes):
- Pre-product (idea stage, no users yet)
- Has users (people using it, not yet paying)
- Has paying customers
Output: "Here's what I understand about this project and the area you want to change: ..."
---
## Phase 2A: Startup Mode — YC Product Diagnostic
Use this mode when the user is building a startup or doing intrapreneurship.
### Operating Principles
These are non-negotiable. They shape every response in this mode.
**Specificity is the only currency.** Vague answers get pushed. "Enterprises in healthcare" is not a customer. "Everyone needs this" means you can't find anyone. You need a name, a role, a company, a reason.
**Interest is not demand.** Waitlists, signups, "that's interesting" — none of it counts. Behavior counts. Money counts. Panic when it breaks counts. A customer calling you when your service goes down for 20 minutes — that's demand.
**The user's words beat the founder's pitch.** There is almost always a gap between what the founder says the product does and what users say it does. The user's version is the truth. If your best customers describe your value differently than your marketing copy does, rewrite the copy.
**Watch, don't demo.** Guided walkthroughs teach you nothing about real usage. Sitting behind someone while they struggle — and biting your tongue — teaches you everything. If you haven't done this, that's assignment #1.
**The status quo is your real competitor.** Not the other startup, not the big company — the cobbled-together spreadsheet-and-Slack-messages workaround your user is already living with. If "nothing" is the current solution, that's usually a sign the problem isn't painful enough to act on.
**Narrow beats wide, early.** The smallest version someone will pay real money for this week is more valuable than the full platform vision. Wedge first. Expand from strength.
### Response Posture
- **Be direct to the point of discomfort.** Comfort means you haven't pushed hard enough. Your job is diagnosis, not encouragement. Save warmth for the closing — during the diagnostic, take a position on every answer and state what evidence would change your mind.
- **Push once, then push again.** The first answer to any of these questions is usually the polished version. The real answer comes after the second or third push. "You said 'enterprises in healthcare.' Can you name one specific person at one specific company?"
- **Calibrated acknowledgment, not praise.** When a founder gives a specific, evidence-based answer, name what was good and pivot to a harder question: "That's the most specific demand evidence in this session — a customer calling you when it broke. Let's see if your wedge is equally sharp." Don't linger. The best reward for a good answer is a harder follow-up.
- **Name common failure patterns.** If you recognize a common failure mode — "solution in search of a problem," "hypothetical users," "waiting to launch until it's perfect," "assuming interest equals demand" — name it directly.
- **End with the assignment.** Every session should produce one concrete thing the founder should do next. Not a strategy — an action.
### Anti-Sycophancy Rules
**Never say these during the diagnostic:**
- "That's an interesting approach" — take a position instead
- "There are many ways to think about this" — pick one and state what evidence would change your mind
- "You might want to consider..." — say "This is wrong because..." or "This works because..."
- "That could work" — say whether it WILL work based on the evidence you have, and what evidence is missing
- "I can see why you'd think that" — if they're wrong, say they're wrong and why
**Always do:**
- Take a position on every answer. State your position AND what evidence would change it. This is rigor — not hedging, not fake certainty.
- Challenge the strongest version of the founder's claim, not a strawman.
### Pushback Patterns — How to Push
These examples show the difference between soft exploration and rigorous diagnosis:
**Pattern 1: Vague market → force specificity**
- Founder: "I'm building an AI tool for developers"
- BAD: "That's a big market! Let's explore what kind of tool."
- GOOD: "There are 10,000 AI developer tools right now. What specific task does a specific developer currently waste 2+ hours on per week that your tool eliminates? Name the person."
**Pattern 2: Social proof → demand test**
- Founder: "Everyone I've talked to loves the idea"
- BAD: "That's encouraging! Who specifically have you talked to?"
- GOOD: "Loving an idea is free. Has anyone offered to pay? Has anyone asked when it ships? Has anyone gotten angry when your prototype broke? Love is not demand."
**Pattern 3: Platform vision → wedge challenge**
- Founder: "We need to build the full platform before anyone can really use it"
- BAD: "What would a stripped-down version look like?"
- GOOD: "That's a red flag. If no one can get value from a smaller version, it usually means the value proposition isn't clear yet — not that the product needs to be bigger. What's the one thing a user would pay for this week?"
**Pattern 4: Growth stats → vision test**
- Founder: "The market is growing 20% year over year"
- BAD: "That's a strong tailwind. How do you plan to capture that growth?"
- GOOD: "Growth rate is not a vision. Every competitor in your space can cite the same stat. What's YOUR thesis about how this market changes in a way that makes YOUR product more essential?"
**Pattern 5: Undefined terms → precision demand**
- Founder: "We want to make onboarding more seamless"
- BAD: "What does your current onboarding flow look like?"
- GOOD: "'Seamless' is not a product feature — it's a feeling. What specific step in onboarding causes users to drop off? What's the drop-off rate? Have you watched someone go through it?"
### The Six Forcing Questions
Ask these questions **ONE AT A TIME**. Push on each one until the answer is specific, evidence-based, and uncomfortable. Comfort means the founder hasn't gone deep enough.
**Smart routing based on product stage — you don't always need all six:**
- Pre-product → Q1, Q2, Q3
- Has users → Q2, Q4, Q5
- Has paying customers → Q4, Q5, Q6
- Pure engineering/infra → Q2, Q4 only
**Intrapreneurship adaptation:** For internal projects, reframe Q4 as "what's the smallest demo that gets your VP/sponsor to greenlight the project?" and Q6 as "does this survive a reorg — or does it die when your champion leaves?"
#### Q1: Demand Reality
**Ask:** "What's the strongest evidence you have that someone actually wants this — not 'is interested,' not 'signed up for a waitlist,' but would be genuinely upset if it disappeared tomorrow?"
**Push until you hear:** Specific behavior. Someone paying. Someone expanding usage. Someone building their workflow around it. Someone who would have to scramble if you vanished.
**Red flags:** "People say it's interesting." "We got 500 waitlist signups." "VCs are excited about the space." None of these are demand.
**After the founder's first answer to Q1**, check their framing before continuing:
1. **Language precision:** Are the key terms in their answer defined? If they said "AI space," "seamless experience," "better platform" — challenge: "What do you mean by [term]? Can you define it so I could measure it?"
2. **Hidden assumptions:** What does their framing take for granted? "I need to raise money" assumes capital is required. "The market needs this" assumes verified pull. Name one assumption and ask if it's verified.
3. **Real vs. hypothetical:** Is there evidence of actual pain, or is this a thought experiment? "I think developers would want..." is hypothetical. "Three developers at my last company spent 10 hours a week on this" is real.
If the framing is imprecise, **reframe constructively** — don't dissolve the question. Say: "Let me try restating what I think you're actually building: [reframe]. Does that capture it better?" Then proceed with the corrected framing. This takes 60 seconds, not 10 minutes.
#### Q2: Status Quo
**Ask:** "What are your users doing right now to solve this problem — even badly? What does that workaround cost them?"
**Push until you hear:** A specific workflow. Hours spent. Dollars wasted. Tools duct-taped together. People hired to do it manually. Internal tools maintained by engineers who'd rather be building product.
**Red flags:** "Nothing — there's no solution, that's why the opportunity is so big." If truly nothing exists and no one is doing anything, the problem probably isn't painful enough.
#### Q3: Desperate Specificity
**Ask:** "Name the actual human who needs this most. What's their title? What gets them promoted? What gets them fired? What keeps them up at night?"
**Push until you hear:** A name. A role. A specific consequence they face if the problem isn't solved. Ideally something the founder heard directly from that person's mouth.
**Red flags:** Category-level answers. "Healthcare enterprises." "SMBs." "Marketing teams." These are filters, not people. You can't email a category.
#### Q4: Narrowest Wedge
**Ask:** "What's the smallest possible version of this that someone would pay real money for — this week, not after you build the platform?"
**Push until you hear:** One feature. One workflow. Maybe something as simple as a weekly email or a single automation. The founder should be able to describe something they could ship in days, not months, that someone would pay for.
**Red flags:** "We need to build the full platform before anyone can really use it." "We could strip it down but then it wouldn't be differentiated." These are signs the founder is attached to the architecture rather than the value.
**Bonus push:** "What if the user didn't have to do anything at all to get value? No login, no integration, no setup. What would that look like?"
#### Q5: Observation & Surprise
**Ask:** "Have you actually sat down and watched someone use this without helping them? What did they do that surprised you?"
**Push until you hear:** A specific surprise. Something the user did that contradicted the founder's assumptions. If nothing has surprised them, they're either not watching or not paying attention.
**Red flags:** "We sent out a survey." "We did some demo calls." "Nothing surprising, it's going as expected." Surveys lie. Demos are theater. And "as expected" means filtered through existing assumptions.
**The gold:** Users doing something the product wasn't designed for. That's often the real product trying to emerge.
#### Q6: Future-Fit
**Ask:** "If the world looks meaningfully different in 3 years — and it will — does your product become more essential or less?"
**Push until you hear:** A specific claim about how their users' world changes and why that change makes their product more valuable. Not "AI keeps getting better so we keep getting better" — that's a rising tide argument every competitor can make.
**Red flags:** "The market is growing 20% per year." Growth rate is not a vision. "AI will make everything better." That's not a product thesis.
---
**Smart-skip:** If the user's answers to earlier questions already cover a later question, skip it. Only ask questions whose answers aren't yet clear.
**STOP** after each question. Wait for the response before asking the next.
**Escape hatch:** If the user expresses impatience ("just do it," "skip the questions"):
- Say: "I hear you. But the hard questions are the value — skipping them is like skipping the exam and going straight to the prescription. Let me ask two more, then we'll move."
- Consult the smart routing table for the founder's product stage. Ask the 2 most critical remaining questions from that stage's list, then proceed to Phase 3.
- If the user pushes back a second time, respect it — proceed to Phase 3 immediately. Don't ask a third time.
- If only 1 question remains, ask it. If 0 remain, proceed directly.
- Only allow a FULL skip (no additional questions) if the user provides a fully formed plan with real evidence — existing users, revenue numbers, specific customer names. Even then, still run Phase 3 (Premise Challenge) and Phase 4 (Alternatives).
---
## Phase 2B: Builder Mode — Design Partner
Use this mode when the user is building for fun, learning, hacking on open source, at a hackathon, or doing research.
### Operating Principles
1. **Delight is the currency** — what makes someone say "whoa"?
2. **Ship something you can show people.** The best version of anything is the one that exists.
3. **The best side projects solve your own problem.** If you're building it for yourself, trust that instinct.
4. **Explore before you optimize.** Try the weird idea first. Polish later.
### Response Posture
- **Enthusiastic, opinionated collaborator.** You're here to help them build the coolest thing possible. Riff on their ideas. Get excited about what's exciting.
- **Help them find the most exciting version of their idea.** Don't settle for the obvious version.
- **Suggest cool things they might not have thought of.** Bring adjacent ideas, unexpected combinations, "what if you also..." suggestions.
- **End with concrete build steps, not business validation tasks.** The deliverable is "what to build next," not "who to interview."
### Questions (generative, not interrogative)
Ask these **ONE AT A TIME**. The goal is to brainstorm and sharpen the idea, not interrogate.
- **What's the coolest version of this?** What would make it genuinely delightful?
- **Who would you show this to?** What would make them say "whoa"?
- **What's the fastest path to something you can actually use or share?**
- **What existing thing is closest to this, and how is yours different?**
- **What would you add if you had unlimited time?** What's the 10x version?
**Smart-skip:** If the user's initial prompt already answers a question, skip it. Only ask questions whose answers aren't yet clear.
**STOP** after each question. Wait for the response before asking the next.
**Escape hatch:** If the user says "just do it," expresses impatience, or provides a fully formed plan → fast-track to Phase 4 (Alternatives Generation). If user provides a fully formed plan, skip Phase 2 entirely but still run Phase 3 and Phase 4.
**If the vibe shifts mid-session** — the user starts in builder mode but says "actually I think this could be a real company" or mentions customers, revenue, fundraising — upgrade to Startup mode naturally. Say something like: "Okay, now we're talking — let me ask you some harder questions." Then switch to the Phase 2A questions.
---
## Phase 2.5: Related Design Discovery
After the user states the problem (first question in Phase 2A or 2B), search existing design docs for keyword overlap.
Extract 3-5 significant keywords from the user's problem statement and search across any prior design docs for this project.
If matches found, read them and surface:
- "FYI: Related design found — '{title}' on {date}. Key overlap: {1-line summary of relevant section}."
- Ask: "Should we build on this prior design or start fresh?"
This enables discovery — if you've explored this space before, you'll see prior thinking.
If no matches found, proceed silently.
---
## Phase 2.75: Landscape Awareness
After understanding the problem through questioning, search for what the world thinks. This is NOT competitive research. This is understanding conventional wisdom so you can evaluate where it's wrong.
**Privacy gate:** Before searching, ask: "I'd like to search for what the world thinks about this space to inform our discussion. This sends generalized category terms (not your specific idea) to a search provider. OK to proceed?"
- A) Yes, search away
- B) Skip — keep this session private
If B: skip this phase entirely. Use only existing knowledge.
When searching, use **generalized category terms** — never the user's specific product name, proprietary concept, or stealth idea. For example, search "task management app landscape" not "SuperTodo AI-powered task killer."
**Startup mode:** Search for:
- "[problem space] startup approach"
- "[problem space] common mistakes"
- "why [incumbent solution] fails" OR "why [incumbent solution] works"
**Builder mode:** Search for:
- "[thing being built] existing solutions"
- "[thing being built] open source alternatives"
- "best [thing category]"
Read the top 2-3 results. Run the three-layer synthesis:
- **[Layer 1]** What does everyone already know about this space?
- **[Layer 2]** What are the search results and current discourse saying?
- **[Layer 3]** Given what WE learned in Phase 2A/2B — is there a reason the conventional approach is wrong?
**Eureka check:** If Layer 3 reasoning reveals a genuine insight, name it: "EUREKA: Everyone does X because they assume [assumption]. But [evidence from our conversation] suggests that's wrong here. This means [implication]."
If no eureka moment exists, say: "The conventional wisdom seems sound here. Let's build on it." Proceed to Phase 3.
**Important:** This search feeds Phase 3 (Premise Challenge). If you found reasons the conventional approach fails, those become premises to challenge. If conventional wisdom is solid, that raises the bar for any premise that contradicts it.
---
## Phase 3: Premise Challenge
Before proposing solutions, challenge the premises:
1. **Is this the right problem?** Could a different framing yield a dramatically simpler or more impactful solution?
2. **What happens if we do nothing?** Real pain point or hypothetical one?
3. **What existing code already partially solves this?** Map existing patterns, utilities, and flows that could be reused.
4. **If the deliverable is a new artifact** (CLI binary, library, package, container image, mobile app): **how will users get it?** Code without distribution is code nobody can use. The design must include a distribution channel (GitHub Releases, package manager, container registry, app store) — or explicitly defer it.
5. **Startup mode only:** Synthesize the diagnostic evidence from Phase 2A. Does it support this direction? Where are the gaps?
Output premises as clear statements the user must agree with before proceeding:
```
PREMISES:
1. [statement] — agree/disagree?
2. [statement] — agree/disagree?
3. [statement] — agree/disagree?
```
If the user disagrees with a premise, revise understanding and loop back.
---
## Phase 3.5: Cross-Model Second Opinion (optional)
If a second AI model is available (e.g. via a subagent, a different provider, or a tool like Codex), offer a cold read from an independent perspective.
Ask the user:
> Want a second opinion from a different AI model? It will independently review your problem statement, key answers, premises, and any landscape findings from this session. It hasn't seen this conversation — it gets a structured summary. Usually takes 2-5 minutes.
> A) Yes, get a second opinion
> B) No, proceed to alternatives
If B: skip Phase 3.5 entirely. Remember that a second opinion did NOT run (affects design doc and Phase 4 below).
**If A: Run the cold read.**
1. Assemble a structured context block from Phases 1-3:
- Mode (Startup or Builder)
- Problem statement (from Phase 1)
- Key answers from Phase 2A/2B (summarize each Q&A in 1-2 sentences, include verbatim user quotes)
- Landscape findings (from Phase 2.75, if search was run)
- Agreed premises (from Phase 3)
- Codebase context (project name, languages, recent activity)
2. Use the mode-appropriate prompt:
**Startup mode:** "You are an independent technical advisor reading a transcript of a startup brainstorming session. [CONTEXT BLOCK]. Your job: 1) What is the STRONGEST version of what this person is trying to build? Steelman it in 2-3 sentences. 2) What is the ONE thing from their answers that reveals the most about what they should actually build? Quote it and explain why. 3) Name ONE agreed premise you think is wrong, and what evidence would prove you right. 4) If you had 48 hours and one engineer to build a prototype, what would you build? Be specific — tech stack, features, what you'd skip. Be direct. Be terse. No preamble."
**Builder mode:** "You are an independent technical advisor reading a transcript of a builder brainstorming session. [CONTEXT BLOCK]. Your job: 1) What is the COOLEST version of this they haven't considered? 2) What's the ONE thing from their answers that reveals what excites them most? Quote it. 3) What existing open source project or tool gets them 50% of the way there — and what's the 50% they'd need to build? 4) If you had a weekend to build this, what would you build first? Be specific. Be direct. No preamble."
3. **Presentation:**
```
SECOND OPINION:
════════════════════════════════════════════════════════════
<full output, verbatim — do not truncate or summarize>
════════════════════════════════════════════════════════════
```
4. **Cross-model synthesis:** After presenting the output, provide 3-5 bullet synthesis:
- Where you agree with the second opinion
- Where you disagree and why
- Whether the challenged premise changes your recommendation
5. **Premise revision check:** If the second opinion challenged an agreed premise, ask the user:
> The second opinion challenged premise #{N}: "{premise text}". Their argument: "{reasoning}".
> A) Revise this premise based on that input
> B) Keep the original premise — proceed to alternatives
If A: revise the premise and note the revision. If B: proceed (and note that the user defended this premise — this is a founder signal if they articulate WHY they disagree, not just dismiss).
If no second model is available, skip this phase silently and proceed to Phase 4.
---
## Phase 4: Alternatives Generation (MANDATORY)
Produce 2-3 distinct implementation approaches. This is NOT optional.
For each approach:
```
APPROACH A: [Name]
Summary: [1-2 sentences]
Effort: [S/M/L/XL]
Risk: [Low/Med/High]
Pros: [2-3 bullets]
Cons: [2-3 bullets]
Reuses: [existing code/patterns leveraged]
APPROACH B: [Name]
...
APPROACH C: [Name] (optional — include if a meaningfully different path exists)
...
```
Rules:
- At least 2 approaches required. 3 preferred for non-trivial designs.
- One must be the **"minimal viable"** (fewest files, smallest diff, ships fastest).
- One must be the **"ideal architecture"** (best long-term trajectory, most elegant).
- One can be **creative/lateral** (unexpected approach, different framing of the problem).
- If the second opinion proposed a prototype in Phase 3.5, consider using it as a starting point for the creative/lateral approach.
**RECOMMENDATION:** Choose [X] because [one-line reason].
Present the approaches and get user approval before proceeding. Do NOT continue without the user choosing an approach.
---
## Visual Sketch (UI ideas only)
If the chosen approach involves user-facing UI (screens, pages, forms, dashboards, or interactive elements), generate a rough wireframe to help the user visualize it. If the idea is backend-only, infrastructure, or has no UI component — skip this section silently.
**Step 1: Gather design context**
1. Check if `DESIGN.md` exists in the repo root. If it does, read it for design system constraints (colors, typography, spacing, component patterns). Use these constraints in the wireframe.
2. Apply core design principles:
- **Information hierarchy** — what does the user see first, second, third?
- **Interaction states** — loading, empty, error, success, partial
- **Edge case paranoia** — what if the name is 47 chars? Zero results? Network fails?
- **Subtraction default** — "as little design as possible" (Rams). Every element earns its pixels.
- **Design for trust** — every interface element builds or erodes user trust.
**Step 2: Generate wireframe HTML**
Generate a single-page HTML file with these constraints:
- **Intentionally rough aesthetic** — use system fonts, thin gray borders, no color, hand-drawn-style elements. This is a sketch, not a polished mockup.
- Self-contained — no external dependencies, no CDN links, inline CSS only
- Show the core interaction flow (1-3 screens/states max)
- Include realistic placeholder content (not "Lorem ipsum" — use content that matches the actual use case)
- Add HTML comments explaining design decisions
**Step 3: Present and iterate**
Show the wireframe to the user. Ask: "Does this feel right? Want to iterate on the layout?"
If they want changes, regenerate with their feedback. If they approve or say "good enough," proceed.
**Step 4: Include in design doc**
Reference the wireframe in the design doc's "Recommended Approach" section.
**Step 5: Outside design voices** (optional)
After the wireframe is approved, if a second AI model is available, offer outside design perspectives:
> Want outside design perspectives on the chosen approach? A second model can propose a visual thesis, content plan, and interaction ideas.
> A) Yes — get outside design voices
> B) No — proceed without
If A: prompt the second model with: "For this product approach, provide: a visual thesis (one sentence — mood, material, energy), a content plan (hero → support → detail → CTA), and 2 interaction ideas that change page feel. Apply beautiful defaults: composition-first, brand-first, cardless, poster not document. Be opinionated."
Present the output and incorporate useful ideas into the design doc. If unavailable, skip silently.
---
## Phase 4.5: Founder Signal Synthesis
Before writing the design doc, synthesize the founder signals you observed during the session. These will appear in the design doc ("What I noticed") and in the closing conversation (Phase 6).
Track which of these signals appeared during the session:
- Articulated a **real problem** someone actually has (not hypothetical)
- Named **specific users** (people, not categories — "Sarah at Acme Corp" not "enterprises")
- **Pushed back** on premises (conviction, not compliance)
- Their project solves a problem **other people need**
- Has **domain expertise** — knows this space from the inside
- Showed **taste** — cared about getting the details right
- Showed **agency** — actually building, not just planning
- **Defended premise with reasoning** against cross-model challenge (kept original premise when second opinion disagreed AND articulated specific reasoning for why — dismissal without reasoning does not count)
Count the signals. You'll use this count in Phase 6 to determine which tier of closing message to use.
---
## Phase 5: Design Doc
Write the design document.
### Startup mode design doc template:
```markdown
# Design: {title}
Generated by office-hours on {date}
Status: DRAFT
Mode: Startup
## Problem Statement
{from Phase 2A}
## Demand Evidence
{from Q1 — specific quotes, numbers, behaviors demonstrating real demand}
## Status Quo
{from Q2 — concrete current workflow users live with today}
## Target User & Narrowest Wedge
{from Q3 + Q4 — the specific human and the smallest version worth paying for}
## Constraints
{from Phase 2A}
## Premises
{from Phase 3}
## Cross-Model Perspective
{If a second opinion ran in Phase 3.5: their independent cold read — steelman, key insight, challenged premise, prototype suggestion. Verbatim or close paraphrase. If it did NOT run: omit this section entirely.}
## Approaches Considered
### Approach A: {name}
{from Phase 4}
### Approach B: {name}
{from Phase 4}
## Recommended Approach
{chosen approach with rationale}
## Open Questions
{any unresolved questions from the office hours}
## Success Criteria
{measurable criteria from Phase 2A}
## Distribution Plan
{how users get the deliverable — binary download, package manager, container image, web service, etc.}
{omit this section if the deliverable is a web service with existing deployment pipeline}
## Dependencies
{blockers, prerequisites, related work}
## The Assignment
{one concrete real-world action the founder should take next — not "go build it"}
## What I noticed about how you think
{observational, mentor-like reflections referencing specific things the user said during the session. Quote their words back to them — don't characterize their behavior. 2-4 bullets.}
```
### Builder mode design doc template:
```markdown
# Design: {title}
Generated by office-hours on {date}
Status: DRAFT
Mode: Builder
## Problem Statement
{from Phase 2B}
## What Makes This Cool
{the core delight, novelty, or "whoa" factor}
## Constraints
{from Phase 2B}
## Premises
{from Phase 3}
## Cross-Model Perspective
{If a second opinion ran in Phase 3.5: their independent cold read — coolest version, key insight, existing tools, prototype suggestion. Verbatim or close paraphrase. If it did NOT run: omit this section entirely.}
## Approaches Considered
### Approach A: {name}
{from Phase 4}
### Approach B: {name}
{from Phase 4}
## Recommended Approach
{chosen approach with rationale}
## Open Questions
{any unresolved questions from the office hours}
## Success Criteria
{what "done" looks like}
## Distribution Plan
{how users get the deliverable — binary download, package manager, container image, web service, etc.}
## Next Steps
{concrete build tasks — what to implement first, second, third}
## What I noticed about how you think
{observational, mentor-like reflections referencing specific things the user said during the session. Quote their words back to them — don't characterize their behavior. 2-4 bullets.}
```
---
## Spec Review Loop
Before presenting the document to the user for approval, run an adversarial review.
**Step 1: Dispatch a reviewer**
Use a subagent or second model to independently review the document. The reviewer has fresh context and cannot see the brainstorming conversation — only the document. This ensures genuine adversarial independence.
Prompt the reviewer with:
- The document content
- "Read this document and review it on 5 dimensions. For each dimension, note PASS or list specific issues with suggested fixes. At the end, output a quality score (1-10) across all dimensions."
**Dimensions:**
1. **Completeness** — Are all requirements addressed? Missing edge cases?
2. **Consistency** — Do parts of the document agree with each other? Contradictions?
3. **Clarity** — Could an engineer implement this without asking questions? Ambiguous language?
4. **Scope** — Does the document creep beyond the original problem? YAGNI violations?
5. **Feasibility** — Can this actually be built with the stated approach? Hidden complexity?
The reviewer should return:
- A quality score (1-10)
- PASS if no issues, or a numbered list of issues with dimension, description, and fix
**Step 2: Fix and re-dispatch**
If the reviewer returns issues:
1. Fix each issue in the document
2. Re-dispatch the reviewer with the updated document
3. Maximum 3 iterations total
**Convergence guard:** If the reviewer returns the same issues on consecutive iterations (the fix didn't resolve them or the reviewer disagrees with the fix), stop the loop and persist those issues as "Reviewer Concerns" in the document rather than looping further.
If the reviewer is unavailable — skip the review loop entirely. Tell the user: "Spec review unavailable — presenting unreviewed doc." The document is already written; the review is a quality bonus, not a gate.
**Step 3: Report**
Tell the user the result:
"Your doc survived N rounds of adversarial review. M issues caught and fixed. Quality score: X/10."
If issues remain after max iterations or convergence, add a "## Reviewer Concerns" section to the document listing each unresolved issue.
---
Present the reviewed design doc to the user:
- A) Approve — mark Status: APPROVED and proceed to handoff
- B) Revise — specify which sections need changes (loop back to revise those sections)
- C) Start over — return to Phase 2
---
## Phase 6: Handoff — Founder Discovery
Once the design doc is APPROVED, deliver the closing sequence. This is three beats with a deliberate pause between them. Every user gets all three beats regardless of mode.
### Beat 1: Signal Reflection
One paragraph that weaves specific session callbacks with encouragement. Reference actual things the user said — quote their words back to them.
**Anti-slop rule — show, don't tell:**
- GOOD: "You didn't say 'small businesses' — you said 'Sarah, the ops manager at a 50-person logistics company.' That specificity is rare."
- BAD: "You showed great specificity in identifying your target user."
- GOOD: "You pushed back when I challenged premise #2. Most people just agree."
- BAD: "You demonstrated conviction and independent thinking."
Example: "The way you think about this problem — [specific callback] — that's founder thinking. The engineering barrier is lower than ever. What remains is taste — and you just demonstrated that."
### Beat 2: "One more thing."
After the signal reflection, output a separator and "One more thing." — this resets attention and signals the genre shift.
---
One more thing.
### Beat 3: Personal Note
Use the founder signal count from Phase 4.5 to select the right tier.
**Decision rubric:**
- **Top tier:** 3+ strong signals AND at least one of: named a specific user, identified revenue/payment, or described real demand evidence
- **Middle tier:** 1-2 signals, or builder-mode user whose project clearly solves a problem others have
- **Base tier:** Everyone else
**Top tier** — emotional target: *"Someone important believes in me."*
Reflect back the strongest signals you observed. Tell them what you see in how they think. If relevant, mention that what they just experienced — the premise challenges, forced alternatives, narrowest-wedge thinking — is the kind of rigorous product thinking that separates ideas from companies. Encourage them to keep going.
**Middle tier** — emotional target: *"I might be onto something."*
Acknowledge what they're building is real. Point to the specific evidence from the session. Encourage them to keep pushing on the hardest questions.
**Base tier** — emotional target: *"I didn't know I could be a founder."*
Note the skills they demonstrated during the session — taste, ambition, agency, the willingness to sit with hard questions. Tell them that founders are everywhere, and the barrier to building has never been lower. A single person with AI can now build what used to take a team of 20.
### Next-skill recommendations
After the closing, suggest what to do next with the design doc — e.g., implementation planning, design review, architecture review, etc.
---
## Important Rules
- **Never start implementation.** This skill produces design docs, not code. Not even scaffolding.
- **Questions ONE AT A TIME.** Never batch multiple questions into one message.
- **The assignment is mandatory.** Every session ends with a concrete real-world action — something the user should do next, not just "go build it."
- **If user provides a fully formed plan:** skip Phase 2 (questioning) but still run Phase 3 (Premise Challenge) and Phase 4 (Alternatives). Even "simple" plans benefit from premise checking and forced alternatives.
+392
View File
@@ -0,0 +1,392 @@
---
name: release
description: >
Release pipeline for CLI, mobile, web, and server. Guides through version
bumping, building, testing, publishing, and deploying. Replaces the old
interactive release-it flow with a Claude Code-native experience.
Use when user types /release or asks to release, publish, deploy, or ship
any component.
---
# Release
You are the release operator for the Happy monorepo. When invoked, walk the user through releasing the component they choose.
## Step 1: Pick a target
Ask which component to release:
- **CLI** — npm package `happy`
- **Mobile** — Expo/EAS builds for iOS + Android
- **Web** — Docker image + K8s deploy via TeamCity
- **Server** — Docker image + K8s deploy via TeamCity
- **Docs** — GitHub Pages (separate repo)
Present these as options. Wait for the user to pick.
---
## CLI Release
Package: packages/happy-cli
npm name: happy
Registry: https://registry.npmjs.org
Git tags: cli-{version}
Tag namespace note:
- CLI releases use `cli-X.Y.Z`
- Native releases use `native-<runtime-version>`
- OTA releases use `ota-<ota-version>`
- Do not use a bare `vX.Y.Z` tag for Happy releases because multiple release streams coexist in this repo
### Step 2: Gather state
Run these in parallel:
1. `npm view happy dist-tags` — see current latest + beta
2. `cat packages/happy-cli/package.json | grep version` — local version
3. `git status --short` — check for dirty state
4. `git branch --show-current` — confirm branch
5. `git log --oneline -10` — recent commits for release notes context
Present a summary:
```
Local version: X.Y.Z
npm latest: X.Y.Z
npm beta: X.Y.Z-N
Branch: main
Working tree: clean / dirty
```
### Step 3: Pick channel and version
Ask the user:
- **Channel**: `latest` or `beta`
- **Bump type**: For latest: `patch`, `minor`, `major`. For beta: `prerelease` (appends `-N`), or explicit version.
Suggest a sensible default based on the current state. For beta, the next prerelease of the current version. For latest, a patch bump.
Present as options. Wait for confirmation.
### Step 4: Version bump
Edit `packages/happy-cli/package.json` directly — do NOT use `npm version` (it chokes on pnpm workspace protocol).
IMPORTANT: do this **before** build/test for the CLI. The build imports `package.json` and bakes the version into the generated bundle. If you build first and bump later, `happy --version` can still report the old prerelease version even though npm metadata shows the new one.
### Step 5: Build
```bash
cd packages/happy-cli
pnpm --filter happy run build
```
Report success/failure. Stop on failure.
### Step 5b: Self-host server split
The `happy` npm package no longer bundles the self-host server binary or webapp.
Packaged installs resolve those from the separately installed
`happy-server-self-host` package. Do not rebuild or ship `tools/server` or
`tools/webapp` as part of a CLI release.
If the CLI release depends on self-host server changes, release
`happy-server-self-host` separately: regenerate Prisma, build the bundled webapp
with `pnpm --filter happy-server-self-host run bundle:webapp`, then publish the
server package. The server package is a JS/TS npm package; npm handles platform
specific dependencies such as Prisma and sharp normally. Do not pass
`--ignore-scripts` when publishing it; its `prepublishOnly` script rebuilds the
runtime, rebuilds the webapp, and runs tests before npm receives the tarball.
Before handing a server publish to the user, pre-run the full `prepublishOnly`
chain yourself to catch failures early — the `bundle:webapp` step runs a multi-minute
`expo export`, and the server unit suite is **not** part of the GitHub CI gate, so
`main` can be red even when the PR "passed":
```bash
cd packages/happy-server && pnpm run build && pnpm run bundle:webapp && pnpm test
```
(Observed: `1332` merged a `standalone.spec.ts` test that only passes on Windows
because the impl used POSIX `path.basename`; it was red on `main` and would have
aborted the publish at the `prepublishOnly` test step.)
### Step 6: Test (unit only)
```bash
cd packages/happy-cli
pnpm --filter happy exec vitest run --project unit
```
Integration tests are slow and flaky — skip them for releases. Unit tests are the gate.
Expect the unit suite to take around a minute; `src/utils/serverConnectionErrors.test.ts` is particularly slow, so don't mistake a long run for a hang.
Report results. If failures, ask the user whether to proceed or abort.
### Step 7: Publish
```bash
cd packages/happy-cli
pnpm publish --tag {channel} --no-git-checks
```
- `--no-git-checks`: allows dirty working tree (we already verified state)
⚠️ **NEVER pass `--ignore-scripts`.** `prepublishOnly` runs `pnpm test` (build +
unit tests), and **the build re-stamps the version into the bundle** (Step 4).
Skipping it ships whatever stale `dist/` happens to be on disk. Two rationalizations
look reasonable and are both WRONG:
- *"We already built + tested this session, so the scripts are redundant — skip them
to go faster."* That earlier build may predate the version bump (or a dependency
change). The on-disk `dist/` is then stamped with the OLD version, and
`--ignore-scripts` ships it. **This actually happened: `1.1.10-beta.9` was published
with `--ignore-scripts` and shipped a bundle stamped `beta.8`** — `happy --version`
reported `beta.8` while npm metadata said `beta.9`. npm versions are immutable, so
the only fix was bumping to `beta.10` and re-releasing. A wasted version number and
a broken publish, to save one ~1-minute rebuild.
- *"It makes the TLS-failure retries faster."* The `prepublishOnly` rebuild on each
retry is the price of correctness, not overhead to trim. If retries are painful,
change the network (see the TLS note above) — do NOT skip scripts.
If you catch yourself reasoning toward `--ignore-scripts`, stop: there is no case in
this repo where it is correct for a publish.
**MUST use `pnpm publish` — never `npm publish`.** This is a pnpm workspace; `npm
publish` mis-resolves the workspace protocol and the `bin` entries and ships a
broken tarball (a regression was reported for exactly this and the fix was to
standardize on `pnpm publish`). `pnpm publish` is the only supported path. Do not
"fall back" to `npm publish` if pnpm errors — diagnose the pnpm error instead.
**Transient TLS upload failures are expected — retry, don't panic.** The tarball
is large (~160 MB, ~1000 files). The upload to `registry.npmjs.org` frequently
dies mid-stream with:
```
npm error code ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC
npm error ... ssl3_read_bytes:ssl/tls alert bad record mac ...
```
This is network-layer corruption of a single TLS record on the long upload, **not**
a code, auth, or version problem. A single bad record kills the whole stream, so
each fresh attempt has an independent chance to complete. Just re-run the exact
same `pnpm publish` command — it typically succeeds within 23 attempts (it took
3 on the 1.1.10-beta.4 release). Before each retry, confirm it did NOT actually
land (see Step 8); npm rejects re-publishing an already-published version, which
would be a misleading error. A clean success prints `+ happy@X.Y.Z`.
### Step 8: Verify
```bash
npm view happy@{version} version # did the version actually publish?
npm view happy dist-tags # did the channel tag move?
```
Check `npm view happy@X.Y.Z version` first — it returns the version string if the
publish landed (use this between TLS retries to avoid double-publishing, and to
distinguish a real failure from a cosmetic upload error).
⚠️ **This metadata check is necessary but NOT sufficient.** `npm view ... version`
only confirms the tarball was *accepted* — it says nothing about what's *inside* it.
A bundle stamped with the wrong version (the `--ignore-scripts` footgun above) passes
this check cleanly. The authoritative check is the bundle itself in Step 11
(`happy --version` after a real install). Never report a release as done on the
metadata check alone.
Then confirm the new version appears under the correct dist-tag. The tag often
lags the publish by 1040s — poll a few times before concluding it failed; npm
tag propagation is not instant.
### Step 9: Git tag + commit (latest only)
For `latest` releases only:
1. Commit the version bump: `Release version X.Y.Z`
2. Tag: `git tag cli-X.Y.Z`
3. Push: `git push && git push --tags`
For `beta` releases: ask the user if they want to commit the version bump or leave it uncommitted.
If `git push` is rejected because `origin/main` advanced while releasing, fetch and rebase the release commit before retrying:
```bash
git fetch origin main
git rebase --autostash origin/main
git tag -f cli-X.Y.Z
git push && git push --tags
```
Use `--autostash` when the worktree is dirty from unrelated local changes so those edits are preserved. Recreate the tag after rebase because the release commit hash changes.
### Step 10: GitHub Release (latest only)
For `latest` releases, create a GitHub release:
```bash
gh release create cli-X.Y.Z --generate-notes --title "cli-X.Y.Z"
```
### Step 11: Install + verify locally
```bash
npm i -g happy@{channel}
happy --version
happy daemon status
```
Report the installed version and daemon status.
The smoke check must confirm that `happy --version` matches the published version, not just npm metadata. If it reports the old version, rebuild after the version bump and cut a corrective patch release.
---
## Mobile Release
Package: packages/happy-app
Variants: development, preview, production
Platform: Expo SDK 54 / React Native 0.81.4
### Build types
**Always ask the user explicitly what they want to release.** Present these
options in order of popularity:
1. **OTA update (preview)** — push JS bundle to preview channel. Most common release type.
2. **OTA update (production)** — push JS bundle to production channel. Do this after preview OTA is validated.
3. **Native dev build** — when native code changes. Points to dev server with bundled app.
4. **Full native release** — build all profiles (dev + preview + production) to prep for a new native release.
#### OTA Updates
```bash
# Preview (most common)
pnpm --filter happy-app run ota
# Production
pnpm --filter happy-app run ota:production
```
OTA scripts require a message — stdin is not readable from Claude Code, so run the
underlying `eas update` directly with `--message`:
```bash
cd packages/happy-app && APP_ENV=preview NODE_ENV=preview tsx sources/scripts/parseChangelog.ts && pnpm typecheck && eas update --branch preview --message "<message>"
```
#### Native Builds
- **Dev build** — development profile, used when native code changes (points to dev server)
```bash
cd packages/happy-app && eas build --profile development --platform all --non-interactive
```
- **TestFlight / Play Store builds** — use `-store` profiles for distribution via TestFlight and Play Store.
**Always pass `--auto-submit`** so the build goes straight to TestFlight after completion.
```bash
# Preview (TestFlight/internal testing)
cd packages/happy-app && eas build --profile preview-store --platform ios --non-interactive --auto-submit
# Dev (TestFlight, points to dev server)
cd packages/happy-app && eas build --profile development-store --platform ios --non-interactive --auto-submit
# Production (App Store / Play Store submission)
cd packages/happy-app && eas build --profile production --platform ios --non-interactive --auto-submit
```
**IMPORTANT:** Always pass `--non-interactive` to `eas build` commands. Without it,
EAS prompts for Apple account login interactively which breaks in non-TTY contexts
(Claude Code, CI). Remote credentials are already configured on EAS servers.
**IMPORTANT:** Always pass `--auto-submit` to `-store` builds. Without it, the build
finishes but never reaches TestFlight — you have to manually submit with `eas submit`.
### EAS Build Profiles
Profile Distribution Channel Notes
development-store store development Dev build via TestFlight
preview-store store preview TestFlight / Play Store internal testing
production store production App Store / Play Store submission
---
#### Internal / ad-hoc profiles (rarely used)
These install via direct link, NOT TestFlight. Almost never needed — prefer
the `-store` profiles above.
Profile Distribution Channel
development internal development
preview internal preview
Version source is remote (EAS manages build numbers, auto-incremented).
Runtime version "20" — bump when native code changes to invalidate OTA.
### App Store Connect
Apple ID: steve@bulkovo.com
Team ID: 466DQWDR8C
App Store Connect App IDs:
Production: 6748571505 (com.ex3ndr.happy)
Preview: 6749025570 (com.slopus.happy.preview)
Development: 6748984254 (com.slopus.happy.dev)
---
## Web Release
Package: packages/happy-app (same Expo app, web export)
Dockerfile: Dockerfile.webapp
Image: docker.korshakov.com/happy-app:{version}
K8s: packages/happy-app/deploy/happy-app.yaml (3 replicas)
Web releases go through TeamCity (`Lab_HappyWeb`). The config is in the TeamCity UI, not in the repo.
Flow: `expo export --platform web` -> nginx:alpine static serve -> Docker build -> push -> K8s deploy.
Build args: `POSTHOG_API_KEY`, `REVENUE_CAT_STRIPE`.
Guide the user to trigger the TeamCity build, or help with manual Docker builds if needed.
---
## Server Release
Package: packages/happy-server
Dockerfile: Dockerfile.server (production), Dockerfile (standalone w/ PGlite)
Image: docker.korshakov.com/handy-server:{version}
K8s: packages/happy-server/deploy/handy.yaml (1 replica, port 3005)
Server releases go through TeamCity (`Lab_HappyServer`). The config is in the TeamCity UI, not in the repo.
Build: node:20 + python3 + ffmpeg, builds happy-wire + happy-server.
Secrets from Vault: handy-db, handy-master, handy-github, handy-files, handy-e2b, handy-revenuecat, handy-elevenlabs.
Redis: happy-redis StatefulSet (redis:7-alpine, 1Gi persistent volume).
Guide the user to trigger the TeamCity build.
---
## Docs Release
Site: happy.engineering (GitHub Pages)
Repo: github.com/slopus/slopus.github.io
Separate repo, not part of this monorepo. Guide the user to push to that repo.
---
## Writing release notes (the in-app changelog)
`CHANGELOG.md` is regenerated into `changelog.json` and shown **inside the mobile app, on a phone, right after an OTA update**. Write for that reader.
1. **Investigate before writing — use subagents (Opus).** Don't infer from commit titles. Spawn parallel subagents to read the actual code + git history of each candidate change and classify it: user-visible UX vs impl detail, default-on vs gated, new vs polish/fix.
2. **Default-off ⇒ exclude.** A change behind a setting/experimental flag that defaults to OFF (or whose UI entry point is hidden) is a silent ship — omit it until it's on by default. Same for impl / perf-internal / refactor / type-only changes.
3. **Audience is phone users.** Most never touch the CLI or desktop. Be skeptical of CLI-only / desktop-only / web-only / beta-only items — a genuinely strong feature can still be wrong for *this* venue; announce those in CLI release notes / docs / GitHub instead.
4. **Ask, don't assume.** When announce-vs-silent-ship, default state, or scope is unclear, ask the owner and confirm the final include/exclude list before writing. Never headline-announce on your own judgment.
5. **Voice:** benefit-first, terse, em-dash, one line per item, grouped as a dated themed entry like existing ones. Edit `CHANGELOG.md` only, then regenerate via `tsx packages/happy-app/sources/scripts/parseChangelog.ts`.
## Rules
- **Release notes: investigate with subagents, exclude default-off, ask when unsure** — see "Writing release notes" above.
- **Always present options** — never assume which component, channel, or version.
- **Always verify before publishing** — show the user what will be published and get confirmation.
- **Do not bundle self-host server/webapp into `happy`** — self-host runtime and the bundled webapp ship through `happy-server-self-host`, not the main CLI package.
- **Unit tests are the gate, not integration tests** — integration tests are slow and have flaky abort/interrupt tests.
- **Use pnpm publish, not npm publish** — avoids workspace protocol issues.
- **Never use --ignore-scripts for package publishing** — prepublish scripts are the last guard before npm receives the tarball.
- **Never force-push tags** — if a tag exists, stop and ask.
+216
View File
@@ -0,0 +1,216 @@
---
name: sessions
description: "Search and ask questions about coding agent session history across Claude Code, Codex, and Cursor. Use when asking what was worked on, what was tried before, how a problem was investigated across sessions, what happened recently, or any question about past agent sessions. Also use when the user references prior sessions, previous attempts, or past investigations — even without saying 'sessions' explicitly."
---
# /sessions (installed from EveryInc/compound-engineering-plugin ce-sessions)
Search session history across Claude Code, Codex, and Cursor and synthesize findings about what was worked on, tried, decided, or learned in prior sessions.
## Usage
```
/ce-sessions [question or topic]
/ce-sessions
```
## Pre-resolved context
**Git branch (pre-resolved):** !`git rev-parse --abbrev-ref HEAD 2>/dev/null || true`
If the line above resolved to a plain branch name (like `feat/my-branch`), use it for branch filtering and pass it to the synthesis subagent. If it still contains a backtick command string or is empty, derive the branch at runtime instead.
**Repo name (pre-resolved):** !`basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || true`
If the line above resolved to a plain repo folder name, use it for session discovery. Otherwise derive at runtime.
## Note: 2026
The current year is 2026. Use this when interpreting session timestamps.
## Guardrails
These rules apply at all times during orchestration and synthesis.
- **Never read entire session files into context.** Session files can be 1-7MB. Always use the extraction scripts to filter first, then reason over the filtered output.
- **Never extract or reproduce tool call inputs/outputs verbatim.** Summarize what was attempted and what happened.
- **Never include thinking or reasoning block content.** Claude Code thinking blocks are internal reasoning; Codex reasoning blocks are encrypted. Neither is actionable.
- **Never analyze the current session.** Its conversation history is already available to the caller.
- **Surface technical content, not personal content.** Sessions contain everything — credentials, frustration, half-formed opinions. Use judgment about what belongs in a technical summary and what doesn't.
- **Fail fast on access errors.** If session discovery fails on permissions, report the issue immediately. Do not retry the same operation with different tools or approaches — repeated retries waste tokens without changing the outcome.
## Execution
If no question argument is provided, ask what the user wants to know about their session history. Use the platform's blocking question tool: `AskUserQuestion` in Claude Code (call `ToolSearch` with `select:AskUserQuestion` first if its schema isn't loaded), `request_user_input` in Codex, `ask_user` in Gemini, `ask_user` in Pi (requires the `pi-ask-user` extension). Fall back to asking in plain text only when no blocking tool exists in the harness or the call errors (e.g., Codex edit modes) — not because a schema load is required. Never silently skip the question.
### Step 1 — Determine scan window
Infer a time range from the user's question. Start narrow; widen only if a narrow scan finds nothing relevant.
| Signal | Initial scan window |
|--------|---------------------|
| "today", "this morning" | 1 day |
| "recently", "last few days", "this week", or no time signal | 7 days |
| "last few weeks", "this month" | 30 days |
| "last few months", broad feature history | 90 days |
Claude Code retains session history for ~30 days by default. Wider windows may find nothing on Claude Code unless the user has extended retention.
### Step 2 — Discover sessions and extract metadata
Run the discovery + metadata pipeline (preserving the null-delimited xargs hardening that lets `extract-metadata.py` run in batch mode):
```bash
bash scripts/discover-sessions.sh <repo> <days> | tr '\n' '\0' | xargs -0 python3 scripts/extract-metadata.py --cwd-filter <repo>
```
Each output line is a JSON object describing a session (platform, file, size, ts, session, plus platform-specific fields). The final `_meta` line carries `files_processed` and `parse_errors`.
If the inventory's `_meta` line shows `files_processed: 0`, return "no relevant prior sessions" and stop.
If `parse_errors > 0`, note that some sessions could not be parsed and proceed with what was returned.
To narrow the platform set, add `--platform claude`, `--platform codex`, or `--platform cursor` to the `discover-sessions.sh` invocation. Default to all three.
### Step 3 — Filter and rank
Apply these filters in order to pick the sessions worth deep-diving:
1. **Branch filter (Claude Code only).** Keep sessions where `branch == dispatch_branch` exactly, or where the branch name contains a keyword from the question's topic (e.g., a question about "auth middleware" matches branches `feat/auth-fix`, `chore/auth-refactor`). Codex sessions don't carry `gitBranch` — skip this filter for them.
2. **If the branch filter returned zero sessions, or you're processing Codex sessions:**
- Derive 2-4 keywords from the question's topic. For "a recent crash in the auth middleware where session-validation rejects valid tokens", derive `auth,middleware,session,token` (or similar).
- Re-invoke the discovery pipeline with `--keyword K1,K2,...` appended to the `extract-metadata.py` invocation. The script returns sessions with non-zero `match_count` plus per-keyword counts.
- **If `files_matched: 0`, return "no relevant prior sessions" and stop.** Do not extract anything.
- If `files_matched > 0`, treat those sessions as candidates. Rank by `match_count`, break ties by per-keyword counts.
3. **Drop sessions outside the scan window.** Use `last_ts` when available, fall back to `ts`. Discard sessions where both fall before the window start.
4. **Exclude the current session** — its conversation history is already available to the caller.
5. **Apply the deep-dive cap.** Take at most **5 sessions total across all platforms**. Narrow by branch-match → `match_count` → file size > 30KB → recency.
6. **Proceed only if at least one session remains after filtering.** Otherwise return "no relevant prior sessions" and stop.
**Note: `gitBranch` is captured at the first user message only.** A session that began on `main` and did substantive work on a feature branch via mid-session `git checkout` records `branch: "main"`. Branch-match returning nothing is not conclusive evidence — that's why the keyword-filter fallback in step 2 is required.
### Step 4 — Set up scratch space
Create a per-run throwaway scratch directory:
```bash
SCRATCH=$(mktemp -d -t ce-sessions-XXXXXX)
```
Capture the absolute path; thread it into Step 5 and Step 6. The OS handles cleanup on session end; an explicit `rm -rf "$SCRATCH"` at the end of Step 7 is harmless and makes intent explicit.
### Step 5 — Extract per-session content (file-mediated)
For each selected session, run the skeleton extractor with `--output` so content writes directly to the scratch file — extraction bytes never round-trip through the orchestrator's tool results:
```bash
python3 scripts/extract-skeleton.py --output "$SCRATCH/<session-id>.skeleton.txt" < <session-file>
```
Stdout receives only a one-line JSON status (`{"_meta": true, "wrote": "...", "bytes": N, ...}`). Capture `bytes` and `parse_errors` from each status line.
**Conditional tail-extract** — if a skeleton terminates mid-investigation (last visible turn is a tool call with no resolution, or the assistant is mid-debugging without a conclusion), re-extract with a `tail` shape:
```bash
python3 scripts/extract-skeleton.py --output "$SCRATCH/<session-id>.skeleton.tail.txt" < <session-file>
```
(The skeleton script does not accept a `tail:N` cap directly; if a tail-only view is needed, post-process the scratch file in shell with `tail -n 50` after extraction. Use this only when the head output suggests the session was truncated mid-investigation.)
**Conditional errors-mode** — for sessions where investigation dead-ends are likely valuable:
```bash
python3 scripts/extract-errors.py --output "$SCRATCH/<session-id>.errors.txt" < <session-file>
```
Use selectively — only when understanding what went wrong adds value. Cursor agent transcripts don't log tool results, so errors-mode produces nothing for Cursor sessions.
### Step 6 — Dispatch synthesis subagent
Dispatch the `ce-session-historian` subagent via the platform's subagent primitive (`Agent` in Claude Code, `spawn_agent` in Codex, `subagent` in Pi via the `pi-subagents` extension). Omit the `mode` parameter so the user's configured permission settings apply. Run on the mid-tier model (e.g., `model: "sonnet"` in Claude Code) — the synthesizer doesn't need frontier reasoning.
The dispatch prompt is the agent's input contract. Pass these fields:
- `problem_topic` — one sentence naming the concrete question. Lift from the user's argument or, if missing, from the answer to the no-arg prompt.
- `scratch_dir` — absolute path to `$SCRATCH`.
- `sessions` — an array of objects, one per extracted session, each with:
- `path` — absolute path to the skeleton file (and optionally `errors_path` for the errors file when extracted)
- `platform``claude`, `codex`, or `cursor`
- `branch` — git branch when present (Claude Code only)
- `cwd` — working directory when present (Codex only)
- `ts` and `last_ts` — session timestamps
- `match_count` and `keyword_matches` — when keyword filtering was used
- `output_schema` — the structure the agent's response should follow. Default schema:
```
Structure your response with these sections (omit any with no findings):
- What was tried before
- What didn't work
- Key decisions
- Related context
```
When the caller (e.g., `ce-compound`) supplies a schema in the skill argument, pass it through verbatim.
Example dispatch shape:
```
Synthesize findings from these prior sessions:
Problem topic: <one-line topic>
Sessions to read (paths in $SCRATCH):
1. /tmp/ce-sessions-XXXX/abc123.skeleton.txt
platform=claude branch=feat/auth-fix ts=2026-05-01
2. /tmp/ce-sessions-XXXX/def456.skeleton.txt errors=/tmp/ce-sessions-XXXX/def456.errors.txt
platform=codex cwd=/Users/.../my-project ts=2026-05-03
...
Output schema:
- What was tried before
- What didn't work
- Key decisions
- Related context
Filter rule: only surface findings directly relevant to this specific problem.
Ignore unrelated work from the same sessions or branches.
```
The agent reads each path via the platform's native file-read tool and returns prose findings. Bulk extraction content lives only in the agent's subagent context — the orchestrator's working state stays at file paths plus small inventory metadata.
### Step 7 — Return findings
Return the synthesizer's output text to the caller verbatim. If discovery or keyword filtering returned zero sessions (Step 2 or Step 3), return the literal string `no relevant prior sessions` instead.
Optionally clean up scratch:
```bash
rm -rf "$SCRATCH"
```
The OS handles cleanup eventually regardless; the explicit cleanup is for readers who expect it.
## Output
When the caller (typically a user typing `/ce-sessions`, or another skill invoking ce-sessions via the platform's skill-invocation primitive) does not specify an output format, include a brief header noting what was searched:
```
**Sessions searched**: [count] ([N] Claude Code, [N] Codex, [N] Cursor) | [date range]
```
Then the synthesizer's prose findings. When the caller supplies a schema, honor it verbatim and omit the default header.
## Time budget
Stop as soon as a complete answer is available. A confident "no relevant prior sessions" within seconds is a complete answer; do not extend the search to fill time. The structural caps in Step 3 (max 5 sessions deep-dived) and Step 5 (conditional tail/errors extraction) bound runtime by construction.
## Error handling
If the discovery pipeline fails (e.g., unreadable home directory, permission failure), surface the error to the caller. Do not substitute git log, file listings, or other sources — this skill's contract is session metadata and synthesis.
If extraction `--output` write fails (disk full, permission), surface a clear error and do not dispatch the synthesizer with partial paths.
If `_meta` reports `parse_errors > 0` from any script, note partial extraction in the dispatch prompt and proceed; the synthesizer flags partial in findings.
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Discover session files across Claude Code, Codex, and Cursor.
#
# Usage: discover-sessions.sh <repo-name> <days> [--platform claude|codex|cursor]
#
# Outputs one file path per line. Safe in both bash and zsh (all globs guarded).
# Pass output to extract-metadata.py:
# python3 extract-metadata.py --cwd-filter <repo-name> $(bash discover-sessions.sh <repo-name> 7)
#
# Arguments:
# repo-name Folder name of the repo (e.g., "my-repo"). Used for directory matching.
# days Scan window in days (e.g., 7). Files older than this are skipped.
# --platform Restrict to a single platform. Omit to search all.
set -euo pipefail
REPO_NAME="${1:?Usage: discover-sessions.sh <repo-name> <days> [--platform claude|codex|cursor]}"
DAYS="${2:?Usage: discover-sessions.sh <repo-name> <days> [--platform claude|codex|cursor]}"
PLATFORM="${4:-all}"
# Parse optional --platform flag
shift 2
while [ $# -gt 0 ]; do
case "$1" in
--platform) PLATFORM="$2"; shift 2 ;;
*) shift ;;
esac
done
# --- Claude Code ---
discover_claude() {
local base="$HOME/.claude/projects"
[ -d "$base" ] || return 0
# Find all project dirs matching repo name
for dir in "$base"/*"$REPO_NAME"*/; do
[ -d "$dir" ] || continue
find "$dir" -maxdepth 1 -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
done
}
# --- Codex ---
discover_codex() {
for base in "$HOME/.codex/sessions" "$HOME/.agents/sessions"; do
[ -d "$base" ] || continue
# Use mtime-based discovery (consistent with Claude/Cursor) so that
# sessions started before the scan window but still active within it
# are not missed.
find "$base" -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
done
}
# --- Cursor ---
discover_cursor() {
local base="$HOME/.cursor/projects"
[ -d "$base" ] || return 0
for dir in "$base"/*"$REPO_NAME"*/; do
[ -d "$dir" ] || continue
local transcripts="$dir/agent-transcripts"
[ -d "$transcripts" ] || continue
find "$transcripts" -name "*.jsonl" -mtime "-${DAYS}" 2>/dev/null
done
}
# --- Dispatch ---
case "$PLATFORM" in
claude) discover_claude ;;
codex) discover_codex ;;
cursor) discover_cursor ;;
all)
discover_claude
discover_codex
discover_cursor
;;
*)
echo "Unknown platform: $PLATFORM" >&2
exit 1
;;
esac
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Extract error signals from a Claude Code, Codex, or Cursor JSONL session file.
Usage:
cat <session.jsonl> | python3 extract-errors.py
cat <session.jsonl> | python3 extract-errors.py --output PATH
Auto-detects platform from the JSONL structure.
Note: Cursor agent transcripts do not log tool results, so no errors can be extracted.
Finds failed tool calls / commands and outputs them with timestamps.
When --output PATH is given, the extracted error log is written to PATH and
stdout receives only a one-line JSON status (_meta with wrote/bytes/stats).
This lets callers route bulk content to a scratch file without round-tripping
extraction bytes through orchestrator tool results.
Without --output, extracted content goes to stdout and ends with a _meta line.
"""
import argparse
import io
import os
import sys
import json
parser = argparse.ArgumentParser(add_help=True)
parser.add_argument(
"--output",
metavar="PATH",
help="Write extracted errors to PATH instead of stdout. Stdout receives a one-line _meta status.",
)
args = parser.parse_args()
_original_stdout = sys.stdout
if args.output:
sys.stdout = io.StringIO()
stats = {"lines": 0, "parse_errors": 0, "errors_found": 0}
def summarize_error(raw):
"""Extract a short error summary instead of dumping the full payload."""
text = str(raw).strip()
# Take the first non-empty line as the error message
for line in text.split("\n"):
line = line.strip()
if line:
return line[:200]
return text[:200]
def handle_claude(obj):
if obj.get("type") == "user":
content = obj.get("message", {}).get("content", [])
if isinstance(content, list):
for block in content:
if block.get("type") == "tool_result" and block.get("is_error"):
ts = obj.get("timestamp", "")[:19]
summary = summarize_error(block.get("content", ""))
print(f"[{ts}] [error] {summary}")
print("---")
stats["errors_found"] += 1
def handle_codex(obj):
if obj.get("type") == "event_msg":
p = obj.get("payload", {})
if p.get("type") == "exec_command_end":
output = p.get("aggregated_output", "")
stderr = p.get("stderr", "")
command = p.get("command", [])
cmd_str = command[-1] if command else ""
exit_match = None
if "Process exited with code " in output:
try:
code_str = output.split("Process exited with code ")[1].split("\n")[0]
exit_code = int(code_str)
if exit_code != 0:
exit_match = exit_code
except (IndexError, ValueError):
pass
if exit_match is not None or stderr:
ts = obj.get("timestamp", "")[:19]
error_summary = summarize_error(stderr if stderr else output)
print(f"[{ts}] [error] exit={exit_match} cmd={cmd_str[:120]}: {error_summary}")
print("---")
stats["errors_found"] += 1
# Auto-detect platform from first few lines, then process all
detected = None
buffer = []
for line in sys.stdin:
line = line.strip()
if not line:
continue
buffer.append(line)
stats["lines"] += 1
if not detected and len(buffer) <= 10:
try:
obj = json.loads(line)
if obj.get("type") in ("user", "assistant"):
detected = "claude"
elif obj.get("type") in ("session_meta", "turn_context", "response_item", "event_msg"):
detected = "codex"
elif obj.get("role") in ("user", "assistant") and "type" not in obj:
detected = "cursor"
except (json.JSONDecodeError, KeyError):
pass
# Cursor transcripts don't log tool results — no errors to extract
def handle_noop(obj):
pass
handlers = {"claude": handle_claude, "codex": handle_codex, "cursor": handle_noop}
handler = handlers.get(detected, handle_noop)
for line in buffer:
try:
handler(json.loads(line))
except (json.JSONDecodeError, KeyError):
stats["parse_errors"] += 1
print(json.dumps({"_meta": True, **stats}))
if args.output:
body = sys.stdout.getvalue()
sys.stdout = _original_stdout
with open(args.output, "w") as f:
f.write(body)
bytes_written = os.path.getsize(args.output)
print(json.dumps({"_meta": True, "wrote": args.output, "bytes": bytes_written, **stats}))
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""Extract session metadata from Claude Code, Codex, and Cursor JSONL files.
Batch mode (preferred — one invocation for all files):
python3 extract-metadata.py /path/to/dir/*.jsonl
python3 extract-metadata.py file1.jsonl file2.jsonl file3.jsonl
Single-file mode (stdin):
head -20 <session.jsonl> | python3 extract-metadata.py
Auto-detects platform from the JSONL structure.
Outputs one JSON object per file, one per line.
Includes a final _meta line with processing stats.
"""
import sys
import json
import os
MAX_LINES = 25 # Only need first ~25 lines for metadata
def try_claude(lines):
for line in lines:
try:
obj = json.loads(line.strip())
if obj.get("type") == "user" and "gitBranch" in obj:
return {
"platform": "claude",
"branch": obj["gitBranch"],
"ts": obj.get("timestamp", ""),
"session": obj.get("sessionId", ""),
}
except (json.JSONDecodeError, KeyError):
pass
return None
def try_codex(lines):
meta = {}
for line in lines:
try:
obj = json.loads(line.strip())
if obj.get("type") == "session_meta":
p = obj.get("payload", {})
meta["platform"] = "codex"
meta["cwd"] = p.get("cwd", "")
meta["session"] = p.get("id", "")
meta["ts"] = p.get("timestamp", obj.get("timestamp", ""))
meta["source"] = p.get("source", "")
meta["cli_version"] = p.get("cli_version", "")
elif obj.get("type") == "turn_context":
p = obj.get("payload", {})
meta["model"] = p.get("model", "")
meta["cwd"] = meta.get("cwd") or p.get("cwd", "")
except (json.JSONDecodeError, KeyError):
pass
return meta if meta else None
def try_cursor(lines):
"""Cursor agent transcripts: role-based entries, no timestamps or metadata fields."""
for line in lines:
try:
obj = json.loads(line.strip())
# Cursor entries have 'role' at top level but no 'type'
if obj.get("role") in ("user", "assistant") and "type" not in obj:
return {"platform": "cursor"}
except (json.JSONDecodeError, KeyError):
pass
return None
def extract_from_lines(lines):
return try_claude(lines) or try_codex(lines) or try_cursor(lines)
TAIL_BYTES = 16384 # Read last 16KB to find final timestamp past trailing metadata
def get_last_timestamp(filepath, size):
"""Read the tail of a file to find the last message with a timestamp."""
try:
with open(filepath, "rb") as f:
f.seek(max(0, size - TAIL_BYTES))
tail = f.read().decode("utf-8", errors="ignore")
lines = tail.strip().split("\n")
for line in reversed(lines):
try:
obj = json.loads(line.strip())
if "timestamp" in obj:
return obj["timestamp"]
except (json.JSONDecodeError, KeyError):
pass
except (OSError, IOError):
pass
return None
def _extract_user_assistant_text(filepath):
"""Return concatenated user + assistant text content from a session JSONL.
Skips JSONL metadata field names and values (sessionId, gitBranch, uuid,
timestamps, type tags), tool_use blocks (tool names + tool inputs),
tool_result blocks (tool outputs), and thinking/reasoning blocks. Only
content the user or assistant actually said is included.
Without this filtering, common topic words like "session" would match every
JSONL file via the sessionId field, drowning out real content matches.
"""
chunks = []
try:
with open(filepath, "r", errors="replace") as f:
for line in f:
try:
obj = json.loads(line.strip())
except (json.JSONDecodeError, ValueError):
continue
# Claude Code: type-tagged top-level
t = obj.get("type")
if t == "user":
msg = obj.get("message", {})
content = msg.get("content")
if isinstance(content, str):
chunks.append(content)
elif isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
chunks.append(block.get("text", ""))
# Skip tool_result blocks — tool outputs are not user content.
continue
if t == "assistant":
msg = obj.get("message", {})
content = msg.get("content", [])
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
chunks.append(block.get("text", ""))
# Skip tool_use and thinking blocks.
continue
# Codex: payload-typed events
if t == "event_msg":
p = obj.get("payload", {})
if p.get("type") == "user_message":
# Strip Codex/Conductor `<system_instruction>...</system_instruction>`
# wrapper before counting. Without this, generic wrapper terms
# (e.g., "Conductor", environment labels) false-match against
# boilerplate the user did not author. Mirrors the same split
# used in ce-session-extract/scripts/extract-skeleton.py.
msg = p.get("message", "")
if isinstance(msg, str):
parts = msg.split("</system_instruction>")
chunks.append(parts[-1] if parts else msg)
continue
if t == "response_item":
p = obj.get("payload", {})
if p.get("type") == "message" and p.get("role") == "assistant":
for block in p.get("content", []):
if isinstance(block, dict) and block.get("type") == "output_text":
chunks.append(block.get("text", ""))
continue
# Cursor: role-tagged with no top-level type
if obj.get("role") in ("user", "assistant") and "type" not in obj:
msg = obj.get("message", {})
for block in msg.get("content", []) if isinstance(msg.get("content"), list) else []:
if isinstance(block, dict) and block.get("type") == "text":
chunks.append(block.get("text", ""))
continue
except (OSError, IOError):
pass
return "\n".join(chunks)
def count_keyword_matches(filepath, keywords):
"""Case-insensitive substring count for each keyword in user/assistant text.
Returns a dict {original_keyword: count}. Scans only content the user or
assistant said — not JSONL metadata, tool calls, tool outputs, or thinking
blocks — so common topic words like "session" do not false-match against
the sessionId field.
"""
text_lower = _extract_user_assistant_text(filepath).lower()
return {kw: text_lower.count(kw.lower()) for kw in keywords}
def process_file(filepath):
"""Extract metadata only. Keyword scanning is done separately so callers
can apply cheap filters (e.g. --cwd-filter) before paying the full-file
content scan cost."""
try:
size = os.path.getsize(filepath)
with open(filepath, "r") as f:
lines = []
for i, line in enumerate(f):
if i >= MAX_LINES:
break
lines.append(line)
result = extract_from_lines(lines)
if result:
result["file"] = filepath
result["size"] = size
if result["platform"] == "cursor":
# Cursor transcripts have no timestamps in JSONL.
# Use file modification time as the best available signal.
# Derive session ID from the parent directory name (UUID).
mtime = os.path.getmtime(filepath)
from datetime import datetime, timezone
result["ts"] = datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat()
result["session"] = os.path.basename(os.path.dirname(filepath))
else:
last_ts = get_last_timestamp(filepath, size)
if last_ts:
result["last_ts"] = last_ts
return result, None
else:
return None, filepath
except (OSError, IOError) as e:
return None, filepath
# Parse arguments: files and optional --cwd-filter / --keyword
files = []
cwd_filter = None
keywords = None
args = sys.argv[1:]
i = 0
while i < len(args):
if args[i] == "--cwd-filter" and i + 1 < len(args):
cwd_filter = args[i + 1]
i += 2
elif args[i] == "--keyword" and i + 1 < len(args):
keywords = [k for k in args[i + 1].split(",") if k]
i += 2
elif not args[i].startswith("-"):
files.append(args[i])
i += 1
else:
i += 1
if files:
# Batch mode: process all files
processed = 0
parse_errors = 0
filtered = 0
matched = 0
for filepath in files:
if not filepath.endswith(".jsonl"):
continue
result, error = process_file(filepath)
processed += 1
if result:
# Apply CWD filter first: cheap metadata-only check. Skip Codex
# sessions from other repos before paying the full-file keyword
# scan cost — Codex discovery returns sessions across all repos,
# so without this ordering --keyword would scan files that are
# immediately discarded.
if cwd_filter and result.get("cwd") and cwd_filter not in result["cwd"]:
filtered += 1
continue
# Apply keyword scan only after cheap filters pass.
if keywords:
matches = count_keyword_matches(filepath, keywords)
result["keyword_matches"] = matches
result["match_count"] = sum(matches.values())
if result["match_count"] == 0:
continue
matched += 1
print(json.dumps(result))
elif error:
parse_errors += 1
meta = {"_meta": True, "files_processed": processed, "parse_errors": parse_errors}
if filtered:
meta["filtered_by_cwd"] = filtered
if keywords:
meta["files_matched"] = matched
print(json.dumps(meta))
else:
# No file arguments: either single-file stdin mode or empty xargs invocation.
# When xargs runs us with no input (e.g., discover found no files), stdin is
# empty or a TTY — emit a clean zero-file result instead of a false parse error.
if sys.stdin.isatty():
lines = []
else:
lines = list(sys.stdin)
if not lines:
# No input at all — zero-file result (clean exit for empty pipelines).
# When --keyword was supplied, emit files_matched: 0 so callers relying
# on its presence to terminate quickly in zero-match scans see a
# consistent shape with the batch-mode no-match case.
meta = {"_meta": True, "files_processed": 0, "parse_errors": 0}
if keywords:
meta["files_matched"] = 0
print(json.dumps(meta))
else:
# Genuine single-file stdin mode (backward compatible)
result = extract_from_lines(lines)
if result:
print(json.dumps(result))
print(json.dumps({"_meta": True, "files_processed": 1, "parse_errors": 0 if result else 1}))
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env python3
"""Extract the conversation skeleton from a Claude Code, Codex, or Cursor JSONL session file.
Usage:
cat <session.jsonl> | python3 extract-skeleton.py
cat <session.jsonl> | python3 extract-skeleton.py --output PATH
Auto-detects platform (Claude Code, Codex, or Cursor) from the JSONL structure.
Extracts:
- User messages (text only, no tool results)
- Assistant text (no thinking/reasoning blocks)
- Collapsed tool call summaries (consecutive same-tool calls grouped)
Consecutive tool calls of the same type are collapsed:
3+ Read calls -> "[tools] 3x Read (file1, file2, +1 more) -> all ok"
Codex call/result pairs are deduplicated (only the result with status is kept).
When --output PATH is given, the extracted skeleton is written to PATH and
stdout receives only a one-line JSON status (_meta with wrote/bytes/stats).
This lets callers route bulk content to a scratch file without round-tripping
extraction bytes through orchestrator tool results.
Without --output, extracted content goes to stdout and ends with a _meta line.
"""
import argparse
import io
import os
import sys
import json
import re
parser = argparse.ArgumentParser(add_help=True)
parser.add_argument(
"--output",
metavar="PATH",
help="Write extracted skeleton to PATH instead of stdout. Stdout receives a one-line _meta status.",
)
args = parser.parse_args()
# Capture-and-redirect when --output is set: prints in the rest of the script
# go to the buffer; at the end the buffer is written to PATH and a status
# line is emitted to the real stdout.
_original_stdout = sys.stdout
if args.output:
sys.stdout = io.StringIO()
stats = {"lines": 0, "parse_errors": 0, "user": 0, "assistant": 0, "tool": 0}
# Claude Code wrapper tags to strip from user message content.
# Strip entirely (tag + content): framework noise and raw command output.
# Strip tags only (keep content): command-message, command-name, command-args, user_query.
_STRIP_BLOCK = re.compile(
r"<(?:task-notification|local-command-caveat|local-command-stdout|local-command-stderr|system-reminder)[^>]*>.*?</(?:task-notification|local-command-caveat|local-command-stdout|local-command-stderr|system-reminder)>",
re.DOTALL,
)
_STRIP_TAG = re.compile(
r"</?(?:command-message|command-name|command-args|user_query)[^>]*>"
)
def clean_text(text):
"""Strip framework wrapper tags from message text (Claude and Cursor)."""
text = _STRIP_BLOCK.sub("", text)
text = _STRIP_TAG.sub("", text)
text = re.sub(r"\n{3,}", "\n\n", text).strip()
return text
# Buffer for pending tool entries: [{"ts", "name", "target", "status"}]
pending_tools = []
def flush_tools():
"""Print buffered tool entries, collapsing consecutive same-name groups."""
if not pending_tools:
return
# Group consecutive entries by tool name
groups = []
for entry in pending_tools:
if groups and groups[-1][0]["name"] == entry["name"]:
groups[-1].append(entry)
else:
groups.append([entry])
for group in groups:
name = group[0]["name"]
if len(group) <= 2:
# Print individually
for e in group:
status = f" -> {e['status']}" if e.get("status") else ""
ts_prefix = f"[{e['ts']}] " if e.get("ts") else ""
print(f"{ts_prefix}[tool] {name} {e['target']}{status}")
stats["tool"] += 1
else:
# Collapse
ts = group[0].get("ts", "")
targets = [e["target"] for e in group if e.get("target")]
ok = sum(1 for e in group if e.get("status") == "ok")
err = sum(1 for e in group if e.get("status") and e["status"] != "ok")
no_status = len(group) - ok - err
# Show first 2 targets, then "+N more"
if len(targets) > 2:
target_str = ", ".join(targets[:2]) + f", +{len(targets) - 2} more"
elif targets:
target_str = ", ".join(targets)
else:
target_str = ""
if no_status == len(group):
status_str = ""
elif err == 0:
status_str = " -> all ok"
else:
status_str = f" -> {ok} ok, {err} error"
ts_prefix = f"[{ts}] " if ts else ""
print(f"{ts_prefix}[tools] {len(group)}x {name} ({target_str}){status_str}")
stats["tool"] += len(group)
pending_tools.clear()
def _safe_slice(value, n):
"""Slice value if it is a string; otherwise return ''.
Some Claude Code / MCP tool inputs put structured data (dicts, lists) in
fields like `query` or `prompt`. `dict[:N]` raises TypeError, so guard
every slice with an isinstance check.
"""
return value[:n] if isinstance(value, str) else ""
def summarize_claude_tool(block):
"""Extract name and target from a Claude Code tool_use block."""
name = block.get("name", "unknown")
inp = block.get("input", {})
fp = inp.get("file_path")
p = inp.get("path")
target = (
(fp if isinstance(fp, str) else None)
or (p if isinstance(p, str) else None)
or _safe_slice(inp.get("command"), 120)
or _safe_slice(inp.get("pattern"), 200)
or _safe_slice(inp.get("query"), 80)
or _safe_slice(inp.get("prompt"), 80)
or ""
)
if isinstance(target, str) and len(target) > 120:
target = target[:120]
return name, target
def handle_claude(obj):
msg_type = obj.get("type")
ts = obj.get("timestamp", "")[:19]
if msg_type == "user":
msg = obj.get("message", {})
content = msg.get("content", "")
if isinstance(content, list):
for block in content:
if block.get("type") == "tool_result":
is_error = block.get("is_error", False)
status = "error" if is_error else "ok"
tool_use_id = block.get("tool_use_id")
matched = False
if tool_use_id:
for entry in pending_tools:
if entry.get("id") == tool_use_id:
entry["status"] = status
matched = True
break
if not matched:
# Fallback: assign to earliest pending entry without a status
for entry in pending_tools:
if not entry.get("status"):
entry["status"] = status
break
texts = [
c.get("text", "")
for c in content
if c.get("type") == "text" and len(c.get("text", "")) > 10
]
content = " ".join(texts)
if isinstance(content, str):
content = clean_text(content)
if len(content) > 15:
flush_tools()
print(f"[{ts}] [user] {content[:800]}")
print("---")
stats["user"] += 1
elif msg_type == "assistant":
msg = obj.get("message", {})
content = msg.get("content", [])
if isinstance(content, list):
has_text = False
for block in content:
if block.get("type") == "text":
text = clean_text(block.get("text", ""))
if len(text) > 20:
if not has_text:
flush_tools()
has_text = True
print(f"[{ts}] [assistant] {text[:800]}")
print("---")
stats["assistant"] += 1
elif block.get("type") == "tool_use":
name, target = summarize_claude_tool(block)
entry = {"ts": ts, "name": name, "target": target}
tool_id = block.get("id")
if tool_id:
entry["id"] = tool_id
pending_tools.append(entry)
def handle_codex(obj):
msg_type = obj.get("type")
ts = obj.get("timestamp", "")[:19]
if msg_type == "event_msg":
p = obj.get("payload", {})
if p.get("type") == "user_message":
text = p.get("message", "")
if isinstance(text, str) and len(text) > 15:
parts = text.split("</system_instruction>")
user_text = parts[-1].strip() if parts else text
if len(user_text) > 15:
flush_tools()
print(f"[{ts}] [user] {user_text[:800]}")
print("---")
stats["user"] += 1
elif p.get("type") == "exec_command_end":
# This is the deduplicated result — has status info
command = p.get("command", [])
cmd_str = command[-1] if command else ""
output = p.get("aggregated_output", "")
status = "ok"
if "Process exited with code " in output:
try:
code = int(output.split("Process exited with code ")[1].split("\n")[0])
if code != 0:
status = f"error(exit {code})"
except (IndexError, ValueError):
pass
if cmd_str:
# Shorten common patterns for readability
short_cmd = cmd_str[:120]
pending_tools.append({"ts": ts, "name": "exec", "target": short_cmd, "status": status})
elif msg_type == "response_item":
p = obj.get("payload", {})
if p.get("type") == "message" and p.get("role") == "assistant":
for block in p.get("content", []):
if block.get("type") == "output_text" and len(block.get("text", "")) > 20:
flush_tools()
print(f"[{ts}] [assistant] {block['text'][:800]}")
print("---")
stats["assistant"] += 1
# Skip function_call — exec_command_end is the deduplicated version with status
def handle_cursor(obj):
"""Cursor agent transcripts: role-based, no timestamps, same content structure as Claude."""
role = obj.get("role")
content = obj.get("message", {}).get("content", [])
if role == "user":
texts = []
for block in (content if isinstance(content, list) else []):
if block.get("type") == "text":
texts.append(block.get("text", ""))
text = clean_text(" ".join(texts))
if len(text) > 15:
flush_tools()
# No timestamps available in Cursor transcripts
print(f"[user] {text[:800]}")
print("---")
stats["user"] += 1
elif role == "assistant":
has_text = False
for block in (content if isinstance(content, list) else []):
if block.get("type") == "text":
text = block.get("text", "")
# Skip [REDACTED] placeholder blocks
if len(text) > 20 and text.strip() != "[REDACTED]":
if not has_text:
flush_tools()
has_text = True
print(f"[assistant] {text[:800]}")
print("---")
stats["assistant"] += 1
elif block.get("type") == "tool_use":
name = block.get("name", "unknown")
inp = block.get("input", {})
p = inp.get("path")
fp = inp.get("file_path")
target = (
(p if isinstance(p, str) else None)
or (fp if isinstance(fp, str) else None)
or _safe_slice(inp.get("command"), 120)
or _safe_slice(inp.get("pattern"), 200)
or _safe_slice(inp.get("glob_pattern"), 200)
or _safe_slice(inp.get("target_directory"), 200)
or ""
)
if isinstance(target, str) and len(target) > 120:
target = target[:120]
# No status info available — Cursor doesn't log tool results
pending_tools.append({"ts": "", "name": name, "target": target})
# Auto-detect platform from first few lines, then process all
detected = None
buffer = []
for line in sys.stdin:
line = line.strip()
if not line:
continue
buffer.append(line)
stats["lines"] += 1
if not detected and len(buffer) <= 10:
try:
obj = json.loads(line)
if obj.get("type") in ("user", "assistant"):
detected = "claude"
elif obj.get("type") in ("session_meta", "turn_context", "response_item", "event_msg"):
detected = "codex"
elif obj.get("role") in ("user", "assistant") and "type" not in obj:
detected = "cursor"
except (json.JSONDecodeError, KeyError):
pass
handlers = {"claude": handle_claude, "codex": handle_codex, "cursor": handle_cursor}
handler = handlers.get(detected, handle_codex)
for line in buffer:
try:
handler(json.loads(line))
except (json.JSONDecodeError, KeyError):
stats["parse_errors"] += 1
# Flush any remaining buffered tools
flush_tools()
print(json.dumps({"_meta": True, **stats}))
if args.output:
body = sys.stdout.getvalue()
sys.stdout = _original_stdout
with open(args.output, "w") as f:
f.write(body)
bytes_written = os.path.getsize(args.output)
print(json.dumps({"_meta": True, "wrote": args.output, "bytes": bytes_written, **stats}))
+77
View File
@@ -0,0 +1,77 @@
---
name: terminal-emulator
description: Test interactive CLI/TUI applications using @microsoft/tui-test. Use when you need to test CLI tools with interactive prompts, TUI rendering, arrow key navigation, or any command that requires a TTY. Triggers include "test CLI", "test TUI", "run interactively", "automate terminal input", "simulate user input in terminal".
allowed-tools: Bash(npx tui-test:*), Bash(node:*)
---
# Testing Interactive CLI / TUI with @microsoft/tui-test
Playwright-like API for terminals. Real PTY per test. Made by Microsoft.
- **GitHub**: https://github.com/microsoft/tui-test
- **npm**: `@microsoft/tui-test`
## Install
```bash
yarn add -D @microsoft/tui-test
```
## Usage
```typescript
import { test, expect } from '@microsoft/tui-test';
test.use({ program: { file: 'node', args: ['./my-cli.js'] } });
test('selects option and proceeds', async ({ terminal }) => {
await expect(terminal.getByText('Select an option')).toBeVisible();
await terminal.write('\x1B[B'); // Arrow Down
await terminal.submit(); // Enter
await expect(terminal.getByText('Option 2 selected')).toBeVisible();
});
test('matches snapshot', async ({ terminal }) => {
await expect(terminal).toMatchSnapshot();
});
```
## API
```typescript
// Navigation
await terminal.write('\x1B[A'); // Arrow Up
await terminal.write('\x1B[B'); // Arrow Down
await terminal.write('\x1B[C'); // Arrow Right
await terminal.write('\x1B[D'); // Arrow Left
await terminal.submit(); // Enter
await terminal.write('\t'); // Tab
await terminal.write('\x03'); // Ctrl+C
await terminal.write('\x1B'); // Escape
await terminal.write('\x7F'); // Backspace
await terminal.write('hello'); // Type text
// Assertions
await expect(terminal.getByText('pattern')).toBeVisible();
await expect(terminal.getByText('pattern', { full: true })).toBeVisible();
await expect(terminal).toMatchSnapshot();
// Reading
const content = terminal.content; // Full terminal content as string
```
## Running
```bash
npx tui-test # Run all tests
npx tui-test --update-snapshots # Update snapshots
npx tui-test my-test.ts # Run specific test
```
## Reference
- Used by VS Code terminal team
- Real PTY isolation per test (no mocking)
- Auto-waits for terminal renders
- Cross-platform (macOS, Linux, Windows)
- Snapshot testing built-in
+11
View File
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(pnpm typecheck)",
"Bash(yarn typecheck)",
"Bash(git fetch*)",
"Bash(npm view *)",
"Bash(pnpm lint)"
]
}
}
+198
View File
@@ -0,0 +1,198 @@
---
name: agent-browser
description: Browser automation CLI for AI agents. Use this when asked to test something in a real browser.
allowed-tools: Bash(npx agent-browser:*), Bash(agent-browser:*)
---
# Browser Automation with agent-browser
## Core Workflow
Every browser automation follows this pattern:
1. **Navigate**: `agent-browser open <url>`
2. **Snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`)
3. **Interact**: Use refs to click, fill, select
4. **Re-snapshot**: After navigation or DOM changes, get fresh refs
```bash
agent-browser open https://example.com/form
agent-browser snapshot -i
# Output: @e1 [input type="email"], @e2 [input type="password"], @e3 [button] "Submit"
agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "password123"
agent-browser click @e3
agent-browser wait --load networkidle
agent-browser snapshot -i # Check result
```
## Command Chaining
Commands can be chained with `&&` in a single shell invocation. The browser persists between commands via a background daemon, so chaining is safe and more efficient than separate calls.
```bash
# Chain open + wait + snapshot in one call
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
# Chain multiple interactions
agent-browser fill @e1 "user@example.com" && agent-browser fill @e2 "password123" && agent-browser click @e3
# Navigate and capture
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png
```
**When to chain:** Use `&&` when you don't need to read the output of an intermediate command before proceeding (e.g., open + wait + screenshot). Run commands separately when you need to parse the output first (e.g., snapshot to discover refs, then interact using those refs).
## Essential Commands
```bash
# Navigation
agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser close # Close browser
# Snapshot
agent-browser snapshot -i # Interactive elements with refs (recommended)
agent-browser snapshot -i -C # Include cursor-interactive elements (divs with onclick, cursor:pointer)
agent-browser snapshot -s "#selector" # Scope to CSS selector
# Interaction (use @refs from snapshot)
agent-browser click @e1 # Click element
agent-browser click @e1 --new-tab # Click and open in new tab
agent-browser fill @e2 "text" # Clear and type text
agent-browser type @e2 "text" # Type without clearing
agent-browser select @e1 "option" # Select dropdown option
agent-browser check @e1 # Check checkbox
agent-browser press Enter # Press key
agent-browser keyboard type "text" # Type at current focus (no selector)
agent-browser keyboard inserttext "text" # Insert without key events
agent-browser scroll down 500 # Scroll page
agent-browser scroll down 500 --selector "div.content" # Scroll within a specific container
# Get information
agent-browser get text @e1 # Get element text
agent-browser get url # Get current URL
agent-browser get title # Get page title
# Wait
agent-browser wait @e1 # Wait for element
agent-browser wait --load networkidle # Wait for network idle
agent-browser wait --url "**/page" # Wait for URL pattern
agent-browser wait 2000 # Wait milliseconds
# Downloads
agent-browser download @e1 ./file.pdf # Click element to trigger download
agent-browser wait --download ./output.zip # Wait for any download to complete
agent-browser --download-path ./downloads open <url> # Set default download directory
# Capture
agent-browser screenshot # Screenshot to temp dir
agent-browser screenshot --full # Full page screenshot
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
agent-browser pdf output.pdf # Save as PDF
# Diff (compare page states)
agent-browser diff snapshot # Compare current vs last snapshot
agent-browser diff snapshot --baseline before.txt # Compare current vs saved file
agent-browser diff screenshot --baseline before.png # Visual pixel diff
agent-browser diff url <url1> <url2> # Compare two pages
agent-browser diff url <url1> <url2> --wait-until networkidle # Custom wait strategy
agent-browser diff url <url1> <url2> --selector "#main" # Scope to element
```
## Common Patterns
### Form Submission
```bash
agent-browser open https://example.com/signup
agent-browser snapshot -i
agent-browser fill @e1 "Jane Doe"
agent-browser fill @e2 "jane@example.com"
agent-browser select @e3 "California"
agent-browser check @e4
agent-browser click @e5
agent-browser wait --load networkidle
```
### Authentication with State Persistence
```bash
# Login once and save state
agent-browser open https://app.example.com/login
agent-browser snapshot -i
agent-browser fill @e1 "$USERNAME"
agent-browser fill @e2 "$PASSWORD"
agent-browser click @e3
agent-browser wait --url "**/dashboard"
agent-browser state save auth.json
# Reuse in future sessions
agent-browser state load auth.json
agent-browser open https://app.example.com/dashboard
```
### Data Extraction
```bash
agent-browser open https://example.com/products
agent-browser snapshot -i
agent-browser get text @e5 # Get specific element text
agent-browser get text body > page.txt # Get all page text
# JSON output for parsing
agent-browser snapshot -i --json
agent-browser get text @e1 --json
```
## Ref Lifecycle (Important)
Refs (`@e1`, `@e2`, etc.) are invalidated when the page changes. Always re-snapshot after:
- Clicking links or buttons that navigate
- Form submissions
- Dynamic content loading (dropdowns, modals)
```bash
agent-browser click @e5 # Navigates to new page
agent-browser snapshot -i # MUST re-snapshot
agent-browser click @e1 # Use new refs
```
## Annotated Screenshots (Vision Mode)
Use `--annotate` to take a screenshot with numbered labels overlaid on interactive elements.
```bash
agent-browser screenshot --annotate
# Output includes the image path and a legend:
# [1] @e1 button "Submit"
# [2] @e2 link "Home"
# [3] @e3 textbox "Email"
agent-browser click @e2 # Click using ref from annotated screenshot
```
## JavaScript Evaluation
```bash
# Simple expressions
agent-browser eval 'document.title'
# Complex JS: use --stdin with heredoc (RECOMMENDED)
agent-browser eval --stdin <<'EVALEOF'
JSON.stringify(
Array.from(document.querySelectorAll("img"))
.filter(i => !i.alt)
.map(i => ({ src: i.src.split("/").pop(), width: i.width }))
)
EVALEOF
```
## Session Management and Cleanup
Always close your browser session when done:
```bash
agent-browser close # Close default session
agent-browser --session agent1 close # Close specific session
```
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/control-flow
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/maintain
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/metrics-graphana
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/release
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/sessions
+77
View File
@@ -0,0 +1,77 @@
---
name: terminal-emulator
description: Test interactive CLI/TUI applications using @microsoft/tui-test. Use when you need to test CLI tools with interactive prompts, TUI rendering, arrow key navigation, or any command that requires a TTY. Triggers include "test CLI", "test TUI", "run interactively", "automate terminal input", "simulate user input in terminal".
allowed-tools: Bash(npx tui-test:*), Bash(node:*)
---
# Testing Interactive CLI / TUI with @microsoft/tui-test
Playwright-like API for terminals. Real PTY per test. Made by Microsoft.
- **GitHub**: https://github.com/microsoft/tui-test
- **npm**: `@microsoft/tui-test`
## Install
```bash
yarn add -D @microsoft/tui-test
```
## Usage
```typescript
import { test, expect } from '@microsoft/tui-test';
test.use({ program: { file: 'node', args: ['./my-cli.js'] } });
test('selects option and proceeds', async ({ terminal }) => {
await expect(terminal.getByText('Select an option')).toBeVisible();
await terminal.write('\x1B[B'); // Arrow Down
await terminal.submit(); // Enter
await expect(terminal.getByText('Option 2 selected')).toBeVisible();
});
test('matches snapshot', async ({ terminal }) => {
await expect(terminal).toMatchSnapshot();
});
```
## API
```typescript
// Navigation
await terminal.write('\x1B[A'); // Arrow Up
await terminal.write('\x1B[B'); // Arrow Down
await terminal.write('\x1B[C'); // Arrow Right
await terminal.write('\x1B[D'); // Arrow Left
await terminal.submit(); // Enter
await terminal.write('\t'); // Tab
await terminal.write('\x03'); // Ctrl+C
await terminal.write('\x1B'); // Escape
await terminal.write('\x7F'); // Backspace
await terminal.write('hello'); // Type text
// Assertions
await expect(terminal.getByText('pattern')).toBeVisible();
await expect(terminal.getByText('pattern', { full: true })).toBeVisible();
await expect(terminal).toMatchSnapshot();
// Reading
const content = terminal.content; // Full terminal content as string
```
## Running
```bash
npx tui-test # Run all tests
npx tui-test --update-snapshots # Update snapshots
npx tui-test my-test.ts # Run specific test
```
## Reference
- Used by VS Code terminal team
- Real PTY isolation per test (no mocking)
- Auto-waits for terminal renders
- Cross-platform (macOS, Linux, Windows)
- Snapshot testing built-in
+2
View File
@@ -0,0 +1,2 @@
[mcp_servers.paper]
url = "http://127.0.0.1:29979/mcp"
+14
View File
@@ -0,0 +1,14 @@
.git
.DS_Store
.claude
.dev
node_modules
**/node_modules
**/dist
**/.claude
**/.expo
**/.turbo
**/.next
**/ios/Pods
**/src-tauri/target
**/target
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

+256
View File
@@ -0,0 +1,256 @@
name: CLI Smoke Test
on:
push:
branches: [ main ]
paths:
- 'packages/happy-cli/**'
- 'packages/happy-server/**'
- '.github/workflows/cli-smoke-test.yml'
pull_request:
branches: [ main ]
paths:
- 'packages/happy-cli/**'
- 'packages/happy-server/**'
- '.github/workflows/cli-smoke-test.yml'
workflow_dispatch:
jobs:
smoke-test-linux:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 24]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build package
run: pnpm --filter happy build
# `happy server` resolves the self-host runtime and bundled webapp from
# happy-server-self-host. The server package is JS/TS and lets npm install
# platform-specific dependencies such as Prisma and sharp normally.
- name: Generate Prisma client + bundle happy-server-self-host webapp
run: |
pnpm --filter happy-server-self-host generate
pnpm --filter happy-server-self-host run bundle:webapp
- name: Pack packages
run: |
pnpm --filter happy pack --pack-destination packages/happy-cli
pnpm --filter happy-server-self-host pack --pack-destination packages/happy-server
- name: Install packed packages globally
run: |
HAPPY_PACKAGE_FILE=$(ls packages/happy-cli/*.tgz)
SERVER_PACKAGE_FILE=$(ls packages/happy-server/*.tgz)
npm install -g "./$SERVER_PACKAGE_FILE" "./$HAPPY_PACKAGE_FILE"
- name: Test binary execution
run: |
# Test that the binary starts successfully
echo "Testing happy --help..."
timeout 30s happy --help || {
echo "Error: happy --help failed or timed out"
exit 1
}
echo "Testing happy --version..."
timeout 10s happy --version || {
echo "Error: happy --version failed or timed out"
exit 1
}
echo "Testing happy doctor..."
timeout 30s happy doctor || {
echo "Error: happy doctor failed or timed out"
exit 1
}
echo "Testing happy daemon status..."
timeout 10s happy daemon status || {
echo "Error: happy daemon status failed or timed out"
exit 1
}
echo "Binary smoke test passed on Linux!"
- name: Test packaged server (happy server)
run: |
export HAPPY_HOME_DIR="$(mktemp -d)"
timeout 90s happy server --port 4505 --host 127.0.0.1 --no-persist --reset > /tmp/happy-server.log 2>&1 &
SERVER_PID=$!
UP=0
for i in $(seq 1 60); do
if curl -sf -m 2 http://127.0.0.1:4505/ -o /dev/null; then UP=1; echo "server up after ${i}s"; break; fi
sleep 1
done
# Exercise the Prisma native query engine via a DB-backed read.
PK=$(node -e "console.log(Buffer.alloc(32).toString('base64'))")
BODY=$(curl -s -m 5 -G http://127.0.0.1:4505/v1/auth/request/status --data-urlencode "publicKey=$PK" || true)
echo "auth/request/status -> $BODY"
kill "$SERVER_PID" 2>/dev/null || true
pkill -f happy-server || true
echo "=== happy server log ==="; cat /tmp/happy-server.log || true
if [ "$UP" != "1" ]; then echo "Error: happy server did not become healthy"; exit 1; fi
if ! echo "$BODY" | grep -q '"status"'; then echo "Error: Prisma-backed endpoint did not respond"; exit 1; fi
if grep -qiE 'PrismaClientInitializationError|could not locate.*Query Engine' /tmp/happy-server.log; then echo "Error: Prisma query engine failed to load"; exit 1; fi
echo "happy server smoke test passed (booted + Prisma query engine OK)"
smoke-test-windows:
runs-on: windows-latest
strategy:
matrix:
node-version: [20, 24]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build package
run: pnpm --filter happy build
- name: Pack package
run: pnpm --filter happy pack --pack-destination packages/happy-cli
- name: Install packed package globally
shell: cmd
run: |
for %%f in (packages\happy-cli\*.tgz) do npm install -g "%%f"
- name: Debug npm global installation structure
shell: cmd
run: |
for /f "tokens=*" %%i in ('npm config get prefix') do set NPM_PREFIX=%%i
echo NPM_PREFIX: %NPM_PREFIX%
echo Listing npm prefix directory:
dir "%NPM_PREFIX%" 2>nul || echo Failed to list NPM_PREFIX
echo.
echo Checking for node_modules in npm prefix:
if exist "%NPM_PREFIX%\node_modules" (
echo Found node_modules directory
dir "%NPM_PREFIX%\node_modules" 2>nul
echo.
echo Checking for happy package:
if exist "%NPM_PREFIX%\node_modules\happy" (
echo Found happy package
dir "%NPM_PREFIX%\node_modules\happy" 2>nul
echo.
echo Checking for dist directory:
if exist "%NPM_PREFIX%\node_modules\happy\dist" (
echo Found dist directory
dir "%NPM_PREFIX%\node_modules\happy\dist" 2>nul
) else (
echo No dist directory found
)
) else (
echo No happy package found
)
) else (
echo No node_modules directory found
)
- name: Test binary execution
shell: cmd
run: |
@echo on
rem Get npm global prefix and add to PATH
for /f "tokens=*" %%i in ('npm config get prefix') do set NPM_PREFIX=%%i
set PATH=%NPM_PREFIX%;%PATH%
echo NPM_PREFIX: %NPM_PREFIX%
echo Current PATH: %PATH%
rem Debug: Check if happy exists in various locations
echo Checking for happy binary...
where happy 2>nul && echo Found happy in PATH || echo happy not found in PATH
if exist "%NPM_PREFIX%\happy.cmd" echo Found happy.cmd in NPM_PREFIX
if exist "%NPM_PREFIX%\node_modules\.bin\happy.cmd" echo Found happy.cmd in .bin directory
dir "%NPM_PREFIX%\*happy*" 2>nul || echo No happy files found in NPM_PREFIX
rem Debug: Check the actual contents and encoding of the installed file
if exist "%NPM_PREFIX%\happy.cmd" (
echo File size and type:
dir "%NPM_PREFIX%\happy.cmd"
echo.
echo First few lines of the file:
type "%NPM_PREFIX%\happy.cmd" | head -5
echo.
echo Hex dump of first 50 bytes to check line endings:
powershell -Command "Get-Content '%NPM_PREFIX%\happy.cmd' -Encoding Byte -TotalCount 50 | ForEach-Object { '{0:X2}' -f $_ } | Join-String -Separator ' '"
echo.
echo Testing with direct path...
"%NPM_PREFIX%\happy.cmd" --help
) else (
echo Testing happy --help...
happy --help
)
if errorlevel 1 (
echo Error: happy --help failed
exit /b 1
)
rem Test version
if exist "%NPM_PREFIX%\happy.cmd" (
"%NPM_PREFIX%\happy.cmd" --version
) else (
happy --version
)
if errorlevel 1 (
echo Error: happy --version failed
exit /b 1
)
rem Test doctor
echo Testing happy doctor...
if exist "%NPM_PREFIX%\happy.cmd" (
"%NPM_PREFIX%\happy.cmd" doctor
) else (
happy doctor
)
if errorlevel 1 (
echo Error: happy doctor failed
exit /b 1
)
rem Test daemon status
echo Testing happy daemon status...
if exist "%NPM_PREFIX%\happy.cmd" (
"%NPM_PREFIX%\happy.cmd" daemon status
) else (
happy daemon status
)
if errorlevel 1 (
echo Error: happy daemon status failed
exit /b 1
)
echo Smoke test passed on Windows!
# We don't need a smoke test for macOS because most contributors are developing on macOS.
+36
View File
@@ -0,0 +1,36 @@
name: Expo App TypeScript typecheck
on:
pull_request:
paths:
- 'packages/happy-app/**'
- '.github/workflows/typecheck.yml'
push:
branches:
- main
paths:
- 'packages/happy-app/**'
- '.github/workflows/typecheck.yml'
jobs:
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: TypeScript typecheck
run: pnpm --filter happy-app typecheck
+78
View File
@@ -0,0 +1,78 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
/ios
/android
/packages/happy-app/ios
/packages/happy-app/android
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
.env
.env.production
# typescript
*.tsbuildinfo
.claude/settings.local.json
.claude/*.lock
.claude/worktrees/
.plan
CLAUDE.local.md
CLAUDE.md
.claude/CLAUDE.md
.dev/worktree/*
# Local dev environments
.environments/
environments/data/
# Development planning notes (keep local, don't commit)
notes/
docs/notes/
# ralphex progress logs
progress*.txt
# OpenCode trace output (script is committed, output is not)
docs/competition/opencode/traces/
# scratch tooling
.cdp-scripts/
happy-workspaces/
# local HAPPY_HOME_DIR for self-host testing
.happy-fully-local/
.happy-dev/
# third party tools
.supervibe/
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"paper": {
"type": "http",
"url": "http://127.0.0.1:29979/mcp"
}
}
}
+4
View File
@@ -0,0 +1,4 @@
shamefully-hoist=true
node-linker=hoisted
strict-peer-dependencies=false
auto-install-peers=true
+12
View File
@@ -0,0 +1,12 @@
# Agent Workflow
## Sync To Main
When the user says `sync to main` or `synt to main`, they mean:
1. Fetch `origin/main`.
2. Rebase the current branch on `origin/main`.
3. Push the current HEAD directly to `main` with a normal push, for example:
`git push origin HEAD:main`
Do not force push for this workflow.
+61
View File
@@ -0,0 +1,61 @@
# Standalone happy-server: single container, no external dependencies
# Uses PGlite (embedded Postgres), local filesystem storage, no Redis
# Stage 1: install dependencies
FROM node:20 AS deps
RUN apt-get update && apt-get install -y python3 make g++ build-essential && rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@10.11.0 --activate
WORKDIR /repo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY scripts ./scripts
COPY patches ./patches
RUN mkdir -p packages/happy-app packages/happy-server packages/happy-cli packages/happy-agent packages/happy-wire
COPY packages/happy-app/package.json packages/happy-app/
COPY packages/happy-server/package.json packages/happy-server/
COPY packages/happy-cli/package.json packages/happy-cli/
COPY packages/happy-agent/package.json packages/happy-agent/
COPY packages/happy-wire/package.json packages/happy-wire/
# Workspace postinstall requirements
COPY packages/happy-app/patches packages/happy-app/patches
COPY packages/happy-server/prisma packages/happy-server/prisma
COPY packages/happy-cli/scripts packages/happy-cli/scripts
COPY packages/happy-cli/tools packages/happy-cli/tools
RUN SKIP_HAPPY_WIRE_BUILD=1 pnpm install --frozen-lockfile
# Stage 2: copy source and type-check
FROM deps AS builder
COPY packages/happy-wire ./packages/happy-wire
COPY packages/happy-server ./packages/happy-server
RUN pnpm --filter @slopus/happy-wire build
RUN pnpm --filter happy-server build
# Stage 3: runtime
FROM node:20-slim AS runner
WORKDIR /repo
RUN apt-get update && apt-get install -y ffmpeg curl && rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production
ENV DATA_DIR=/data
ENV PGLITE_DIR=/data/pglite
COPY --from=builder /repo/node_modules /repo/node_modules
COPY --from=builder /repo/packages/happy-wire /repo/packages/happy-wire
COPY --from=builder /repo/packages/happy-server /repo/packages/happy-server
VOLUME /data
EXPOSE 3005
WORKDIR /repo/packages/happy-server
CMD ["sh", "-c", "../../node_modules/.bin/tsx sources/standalone.ts migrate && exec ../../node_modules/.bin/tsx sources/standalone.ts serve"]
+60
View File
@@ -0,0 +1,60 @@
# Stage 1: install dependencies with workspace context
FROM node:20 AS deps
# Install build dependencies
RUN apt-get update && apt-get install -y python3 ffmpeg make g++ build-essential && rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@10.11.0 --activate
WORKDIR /repo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY scripts ./scripts
COPY patches ./patches
RUN mkdir -p packages/happy-app packages/happy-server packages/happy-cli packages/happy-wire
COPY packages/happy-app/package.json packages/happy-app/
COPY packages/happy-server/package.json packages/happy-server/
COPY packages/happy-cli/package.json packages/happy-cli/
COPY packages/happy-wire/package.json packages/happy-wire/
# Workspace postinstall requirements
COPY packages/happy-app/patches packages/happy-app/patches
COPY packages/happy-server/prisma packages/happy-server/prisma
COPY packages/happy-cli/scripts packages/happy-cli/scripts
COPY packages/happy-cli/tools packages/happy-cli/tools
RUN SKIP_HAPPY_WIRE_BUILD=1 pnpm install --frozen-lockfile
# Stage 2: build the server
FROM deps AS builder
COPY packages/happy-wire ./packages/happy-wire
COPY packages/happy-server ./packages/happy-server
RUN pnpm --filter @slopus/happy-wire build
RUN pnpm --filter happy-server build
# Stage 3: runtime
FROM node:20 AS runner
WORKDIR /repo
# Runtime dependencies
RUN apt-get update && apt-get install -y python3 ffmpeg && rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@10.11.0 --activate
# Set environment to production
ENV NODE_ENV=production
# Copy necessary files from the builder stage
COPY --from=builder /repo/node_modules /repo/node_modules
COPY --from=builder /repo/packages/happy-wire /repo/packages/happy-wire
COPY --from=builder /repo/packages/happy-server /repo/packages/happy-server
# Expose the port the app will run on
EXPOSE 3000
# Command to run the application
COPY --from=builder /repo/package.json /repo/pnpm-workspace.yaml /repo/
CMD ["pnpm", "--filter", "happy-server", "start"]
+107
View File
@@ -0,0 +1,107 @@
# Stage 1: install dependencies with workspace context
FROM node:20-alpine AS deps
RUN corepack enable && corepack prepare pnpm@10.11.0 --activate
WORKDIR /repo
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY scripts ./scripts
COPY patches ./patches
RUN mkdir -p packages/happy-app packages/happy-wire
COPY packages/happy-app/package.json packages/happy-app/
COPY packages/happy-wire/package.json packages/happy-wire/
# Workspace postinstall requirements
COPY packages/happy-app/patches packages/happy-app/patches
# The repo defaults to a hoisted linker for local development. Keep this Docker
# install isolated so unrelated workspace native deps do not get built here.
RUN SKIP_HAPPY_WIRE_BUILD=1 pnpm install \
--frozen-lockfile \
--filter happy-app... \
--config.node-linker=isolated \
--config.shamefully-hoist=false
# Stage 2: build the web app
FROM deps AS builder
ARG POSTHOG_API_KEY=""
ARG REVENUE_CAT_STRIPE=""
ARG HAPPY_SERVER_URL=""
ARG HAPPY_BUILD_COMMIT_SHA=""
ARG HAPPY_BUILD_COMMIT_TIMESTAMP=""
ENV NODE_ENV=production
ENV APP_ENV=production
ENV EXPO_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY
ENV EXPO_PUBLIC_REVENUE_CAT_STRIPE=$REVENUE_CAT_STRIPE
ENV EXPO_PUBLIC_HAPPY_SERVER_URL=$HAPPY_SERVER_URL
ENV HAPPY_BUILD_COMMIT_SHA=$HAPPY_BUILD_COMMIT_SHA
ENV HAPPY_BUILD_COMMIT_TIMESTAMP=$HAPPY_BUILD_COMMIT_TIMESTAMP
COPY packages/happy-wire ./packages/happy-wire
COPY packages/happy-app ./packages/happy-app
RUN pnpm --filter @slopus/happy-wire build
RUN pnpm --filter happy-app exec expo export --platform web --output-dir dist
# Stage 3: runtime with Nginx
FROM nginx:alpine AS runner
COPY --from=builder /repo/packages/happy-app/dist /usr/share/nginx/html
# Remove default nginx configuration
RUN rm /etc/nginx/conf.d/default.conf
# Create custom nginx configuration directly in the Dockerfile
RUN echo 'server { \
listen 80; \
\
location /_expo/ { \
root /usr/share/nginx/html; \
try_files $uri =404; \
} \
\
location /assets/ { \
root /usr/share/nginx/html; \
try_files $uri =404; \
} \
\
location /.well-known/ { \
root /usr/share/nginx/html; \
try_files $uri =404; \
} \
\
location / { \
root /usr/share/nginx/html; \
index index.html index.htm; \
try_files $uri $uri.html $uri/index.html $uri/index.htm $uri/ /index.html /index.htm =404; \
} \
\
error_page 500 502 503 504 /50x.html; \
location = /50x.html { \
root /usr/share/nginx/html; \
try_files $uri @redirect_to_index; \
internal; \
} \
\
error_page 404 = @handle_404; \
\
location @handle_404 { \
root /usr/share/nginx/html; \
try_files /404.html @redirect_to_index; \
internal; \
} \
\
location @redirect_to_index { \
return 302 /; \
} \
}' > /etc/nginx/conf.d/default.conf
# Expose the standard nginx port
EXPOSE 80
# Nginx starts automatically in the foreground with CMD ["nginx", "-g", "daemon off;"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Happy Coder Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+103
View File
@@ -0,0 +1,103 @@
# Privacy Policy for Happy Coder
**Last Updated: January 2025**
## Overview
Happy Coder is committed to protecting your privacy. This policy explains how we handle your data with our zero-knowledge encryption architecture.
## What We Collect
### Encrypted Data
- **Messages and Code**: All your Claude Code conversations and code snippets are end-to-end encrypted on your device before transmission. We store this encrypted data but have no ability to decrypt or read it.
- **Encryption Keys**: When you pair devices, encryption keys are transmitted between your devices in encrypted form. We cannot access or decrypt these keys.
### Metadata (Not Encrypted)
- **Message IDs**: Unique identifiers for message ordering and synchronization
- **Timestamps**: When messages were created and synced
- **Device IDs**: Anonymous identifiers for device pairing
- **Session IDs**: Identifiers for your Claude Code terminal sessions
- **Push Notification Tokens**: Device tokens for sending push notifications via Expo's push notification service
### Analytics (PostHog)
- **Anonymous Events**: We collect basic app usage events through PostHog to improve the app experience
- **Privacy by Design**: All analytics events use an anonymized ID derived from a secret key - we cannot match this back to any user or account
- **No Content Tracking**: We only track basic app usage events, never any message content, code, or personal information
- **Opt-Out Available**: You can disable analytics collection at any time in the app settings
### Subscription Management (Revenue Cat)
- **Account ID**: Revenue Cat uses your account ID to manage subscriptions and enable premium features
- **Backend Integration**: This ID allows us to provide additional features from our backend while maintaining end-to-end encryption for your content
- **Data Separation**: Purchase analytics sent to PostHog use the anonymized ID instead - we cannot match Revenue Cat data with PostHog analytics
## What We Don't Collect
- Your actual code or conversation content (we can't decrypt it)
- Personal information beyond what you voluntarily include in encrypted messages
- Device information beyond anonymous IDs
- Location data
## How We Use Data
### Encrypted Data
- Stored on our servers solely for synchronization between your devices
- Transmitted to your paired devices when requested
- Retained until you delete it through the app
### Metadata
- Message IDs and timestamps are used to maintain proper message ordering
- Device IDs enable secure pairing between your devices
- Session IDs track your Claude Code terminal sessions for synchronization
- Push notification tokens are stored to enable notifications through Expo's service
### Push Notifications
Push notifications are sent directly from your devices to each other, not from our backend. This means:
- We never see the content of your notifications
- Notification content is generated on your device
- Only device-to-device communication occurs for notification content
- We use Expo's push notification service solely as a delivery mechanism
## Data Security
- **End-to-End Encryption**: Using TweetNaCl (same as Signal) for all sensitive data
- **Zero-Knowledge**: We cannot decrypt your data even if compelled
- **Secure Key Exchange**: Encryption keys are transmitted between your devices only in encrypted form that we cannot access
- **Open Source**: Our encryption implementation is publicly auditable
- **No Backdoors**: The architecture makes it impossible for us to access your content
## Data Retention
- Encrypted messages are retained indefinitely until you delete them
- Metadata is retained for system functionality
- Deleted data is permanently removed from our servers within 30 days
## Your Rights
You have the right to:
- Delete all your data through the app
- Export your encrypted data
- Audit our open-source code
- Use the app without providing any personal information
## Data Sharing
We do not share your data with anyone. Period.
## Changes to This Policy
We will notify users of any material changes to this privacy policy through the app. Continued use of the service after changes constitutes acceptance.
## Contact
For privacy concerns or questions:
- GitHub Issues: https://github.com/slopus/happy/issues
## Compliance
Happy Coder is designed with privacy by default and complies with:
- GDPR (General Data Protection Regulation)
- CCPA (California Consumer Privacy Act)
- Privacy by Design principles
---
**Remember**: Your encryption keys are only shared between your own devices in encrypted form. We cannot read your code or conversations even if we wanted to.
+86
View File
@@ -0,0 +1,86 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="/.github/logotype-dark.png">
<source media="(prefers-color-scheme: light)" srcset="/.github/logotype-light.png">
<img src="/.github/logotype-dark.png" width="400" alt="Happy">
</picture>
</div>
<h1 align="center">
Mobile and Web Client for Claude Code & Codex
</h1>
<h4 align="center">
Use Claude Code or Codex from anywhere with end-to-end encryption.
</h4>
<div align="center">
[📱 **iOS App**](https://apps.apple.com/us/app/happy-claude-code-client/id6748571505) • [🤖 **Android App**](https://play.google.com/store/apps/details?id=com.ex3ndr.happy) • [🌐 **Web App**](https://app.happy.engineering) • [🎥 **See a Demo**](https://youtu.be/GCS0OG9QMSE) • [📚 **Documentation**](https://happy.engineering/docs/) • [💬 **Discord**](https://discord.gg/fX9WBAhyfD)
</div>
<img width="5178" height="2364" alt="github" src="/.github/header.png" />
<h3 align="center">
Step 1: Download App
</h3>
<div align="center">
<a href="https://apps.apple.com/us/app/happy-claude-code-client/id6748571505"><img width="135" height="39" alt="appstore" src="https://github.com/user-attachments/assets/45e31a11-cf6b-40a2-a083-6dc8d1f01291" /></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<a href="https://play.google.com/store/apps/details?id=com.ex3ndr.happy"><img width="135" height="39" alt="googleplay" src="https://github.com/user-attachments/assets/acbba639-858f-4c74-85c7-92a4096efbf5" /></a>
</div>
<h3 align="center">
Step 2: Install CLI on your computer
</h3>
```bash
npm install -g happy
```
> Migrated from the `happy-coder` package. Thanks to [@franciscop](https://github.com/franciscop) for donating the `happy` package name!
<h3 align="center">
Step 3: Start using `happy` instead of `claude` or `codex`
</h3>
```bash
# Instead of claude, use:
happy claude
# or
happy codex
```
## How does it work?
On your computer, run `happy` instead of `claude` or `happy codex` instead of `codex` to start your AI through our wrapper. When you want to control your coding agent from your phone, it restarts the session in remote mode. To switch back to your computer, just press any key on your keyboard.
## 🔥 Why Happy Coder?
- 📱 **Mobile access to Claude Code and Codex** - Check what your AI is building while away from your desk
- 🔔 **Push notifications** - Get alerted when Claude Code and Codex needs permission or encounters errors
-**Switch devices instantly** - Take control from phone or desktop with one keypress
- 🔐 **End-to-end encrypted** - Your code never leaves your devices unencrypted
- 🛠️ **Open source** - Audit the code yourself. No telemetry, no tracking
## 📦 Project Components
- **[Happy App](https://github.com/slopus/happy/tree/main/packages/happy-app)** - Web UI + mobile client (Expo)
- **[Happy CLI](https://github.com/slopus/happy/tree/main/packages/happy-cli)** - Command-line interface for Claude Code and Codex
- **[Happy Agent](https://github.com/slopus/happy/tree/main/packages/happy-agent)** - Remote agent control CLI (create, send, monitor sessions)
- **[Happy Server](https://github.com/slopus/happy/tree/main/packages/happy-server)** - Backend server for encrypted sync
## 🏠 Who We Are
We're engineers scattered across Bay Area coffee shops and hacker houses, constantly checking how our AI coding agents are progressing on our pet projects during lunch breaks. Happy Coder was born from the frustration of not being able to peek at our AI coding tools building our side hustles while we're away from our keyboards. We believe the best tools come from scratching your own itch and sharing with the community.
## 📚 Documentation & Contributing
- **[Documentation Website](https://happy.engineering/docs/)** - Learn how to use Happy Coder effectively
- **[Contributing Guide](docs/CONTRIBUTING.md)** - How to contribute, PR guidelines, and development setup
- **[Edit docs at github.com/slopus/slopus.github.io](https://github.com/slopus/slopus.github.io)** - Help improve our documentation and guides
## License
MIT License - see [LICENSE](LICENSE) for details.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`slopus/happy`
- 原始仓库:https://github.com/slopus/happy
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+13
View File
@@ -0,0 +1,13 @@
# Third-Party SaaS
External services we pay for or depend on.
## Active
- **RevenueCat** — in-app purchases, subscription management (iOS/Android/web)
- **ElevenLabs** — voice synthesis for voice assistant
- **PostHog** — product analytics, feature flags, surveys
## Trying
- **[Paper](https://paper.design/)** — collaborative design tool. Really nice design on their own site. Trying the MCP server for now.
+132
View File
@@ -0,0 +1,132 @@
# Contributing to Happy
Happy is built by engineers who use AI coding tools all day — and we built Happy so we could use them from anywhere. Contributions that make Happy better for that workflow are welcome.
If you don't get a response on your PR or issue, tag **@bra1ndump**.
## Contribution Priorities
We review contributions in this order:
1. **Bug fixes** — crashes, broken flows, data loss
2. **UI touchups** — polish, layout fixes, visual consistency
3. **New features** — new capabilities that serve the core use case
4. **Refactors** — code quality improvements, test coverage
5. **Core refactors** — sync engine, RPC layer, server changes (discuss first)
If your contribution is lower on this list, it may take longer to get reviewed. That's not a reflection of its value — it's just how we triage.
## Issues
We currently can't reply to every issue individually. We review them in bulk using AI-assisted triage. They're useful — keep filing them — but PRs with clear fixes will always get priority.
Every issue should start with a **one-paragraph summary** of the problem. Don't bury the lede in reproduction steps or logs. Lead with what's broken and what you expected.
## Pull Requests
### The Rules
1. **Start with a one-paragraph summary.** What was broken or missing? What does this PR do about it? A human skimming 20 PRs needs to understand yours in 10 seconds.
2. **Show proof it works.** Include a video, screenshots, or actual log output demonstrating the fix in a real running app. The "before" state can be described with words. The "after" must be shown visually. Unit tests passing is not enough — show it working end-to-end.
3. **Address Codex review comments before requesting human review.** We use automated Codex reviews on all PRs. Resolve those first — they catch the obvious stuff so human reviewers can focus on the important stuff.
4. **Keep PRs focused.** One fix per PR. One feature per PR. If you touched something unrelated, split it out.
5. **Core changes need a discussion first.** If your PR touches the sync engine, RPC protocol, encryption, or server — open an issue or Discord thread before writing code. These areas affect every user and need design alignment.
### What Makes a Good PR
- **Show proof it works.** Screenshots, screen recordings, or actual log output demonstrating the fix in a real running app. Unit tests passing is not enough — show it working end-to-end.
- Links to the issue it fixes (if one exists)
- Short, clear title (`fix: voice session stuck in connecting state` not `Update voice.ts`)
- No unrelated changes, no drive-by refactors
## Development Setup
### Prerequisites
- Node.js >= 20
- pnpm (`npm install -g pnpm`)
- Git
### Getting Started
```bash
git clone https://github.com/slopus/happy.git
cd happy
pnpm install
```
### Happy App (Mobile + Web)
```bash
pnpm --filter happy-app start # Expo dev server
pnpm --filter happy-app ios:dev # iOS simulator
pnpm --filter happy-app android:dev # Android emulator
pnpm web # Browser (shortcut)
pnpm --filter happy-app typecheck # Run after all changes
```
The app has three build variants — all can be installed simultaneously on the same device:
| Variant | Bundle ID | App Name | Use Case |
|---------|-----------|----------|----------|
| Development | `com.slopus.happy.dev` | Happy (dev) | Local development with hot reload |
| Preview | `com.slopus.happy.preview` | Happy (preview) | Beta testing & OTA updates |
| Production | `com.ex3ndr.happy` | Happy | App Store release |
Swap `ios:dev` for `ios:preview` or `ios:production` (same for `android:`).
#### macOS Desktop (Tauri)
```bash
pnpm --filter happy-app tauri:dev # Run with hot reload
pnpm --filter happy-app tauri:build:dev
```
### Happy CLI
```bash
pnpm --filter happy build
pnpm --filter happy test
pnpm --filter happy cli:install # Build + link this workspace as the global `happy` + restart daemon
```
`cli:install` replaces the `happy` binary installed from npm with a symlink to this workspace.
It reuses `~/.happy/` (auth, sessions) — no separate dev home. To undo:
```bash
npm unlink -g happy && npm i -g happy@latest
```
To sandbox dev data, set `HAPPY_HOME_DIR=~/.happy-dev` in your shell before running `happy`.
### Happy Server
```bash
pnpm --filter happy-server standalone:dev # Local server (no Docker needed)
```
Runs on `localhost:3005` with embedded PGlite. To point the app at your local server:
```bash
EXPO_PUBLIC_HAPPY_SERVER_URL=http://localhost:3005 pnpm --filter happy-app start
```
## Project Structure
This is a monorepo with four packages:
- **happy-app** — React Native + Expo mobile/web client
- **happy-cli** — Node.js CLI that wraps Claude Code and Codex
- **happy-agent** — Remote agent control
- **happy-server** — Backend for encrypted sync
For architecture details, check the [docs/](.) folder or ask Happy itself — it knows how the project is set up.
## Community
- [Discord](https://discord.gg/fX9WBAhyfD) — best place for questions and discussion
- [Documentation](https://happy.engineering/docs/)
+27
View File
@@ -0,0 +1,27 @@
# Happy Docs
This folder documents how Happy works internally, with a focus on protocol, backend architecture, deployment, and the CLI tool. Start here.
## Index
- protocol.md: Wire protocol (WebSocket), payload formats, sequencing, and concurrency rules.
- realtime-sync-and-rpc.md: High-level overview of realtime socket management and RPC control flow.
- api.md: HTTP endpoints and authentication flows.
- encryption.md: Encryption boundaries and on-wire encoding.
- backend-architecture.md: Internal backend structure, data flow, and key subsystems.
- deployment.md: How to deploy the backend and required infrastructure.
- cli-architecture.md: CLI and daemon architecture and how they interact with the server.
- multi-process.md: Deeper multi-replica Socket.IO + Redis streams behavior, failure modes, and integration-test history.
- dev-environments.md: Local `environments/data/` workflow, lab-rat project provisioning, `env:cli` passthrough behavior, and daemon usage.
- session-protocol.md: Unified encrypted chat event protocol.
- session-protocol-claude.md: Claude-specific session-protocol flow (local vs remote launchers, dedupe/restarts).
- plans/provider-envelope-redesign.md: Proposed replacement for the current provider/session envelope design.
- permission-resolution.md: State-based permission mode resolution across app and CLI (including sandbox behavior).
- happy-wire.md: Shared wire schemas/types package and migration notes.
- voice-architecture.md: ElevenLabs voice assistant integration, session routing, context batching, and VAD detection.
- research/: general research notes and exploratory writeups.
- competition/: competitor research, protocol analysis, and comparison notes.
- competition/AGENTS.md: structure and rules for storing competitor research results without committing raw checkouts.
## Conventions
- Paths and field names reflect the current implementation in `packages/happy-server`.
- Examples are illustrative; the canonical source is the code.
+110
View File
@@ -0,0 +1,110 @@
# API
This document covers the HTTP API surface and authentication flows. For WebSocket updates and event payloads, see `protocol.md`. For encryption boundaries and encoding details, see `encryption.md`.
## Method conventions
- **GET** is used for reads.
- **POST** is used for mutations or actions, even when the operation doesn't map cleanly to a single entity.
- **DELETE** is used when intent is unambiguous (e.g., removing a token or deleting a session/artifact).
We intentionally avoid the full REST verb palette because many operations span multiple entities or have non-CRUD semantics.
## Authentication
Most endpoints require `Authorization: Bearer <token>`.
Auth flows:
- `POST /v1/auth`
- Body: `{ publicKey, challenge, signature }` (base64 strings)
- Verifies signature using the provided public key.
- Upserts account by public key and returns `{ success, token }`.
- `POST /v1/auth/request`
- Body: `{ publicKey, supportsV2? }`
- Creates or returns a terminal auth request.
- Response: `{ state: "requested" }` or `{ state: "authorized", token, response }`.
- `GET /v1/auth/request/status?publicKey=...`
- Response: `{ status: "not_found" | "pending" | "authorized", supportsV2 }`.
- `POST /v1/auth/response`
- Body: `{ response, publicKey }` (requires Bearer auth)
- Approves a terminal auth request.
- `POST /v1/auth/account/request`
- Body: `{ publicKey }`
- Similar to terminal auth, but for account linking.
- `POST /v1/auth/account/response`
- Body: `{ response, publicKey }` (requires Bearer auth)
## Endpoint catalog
### Sessions
- `GET /v1/sessions`
- `GET /v2/sessions/active?limit=...`
- `GET /v2/sessions?cursor=cursor_v1_<id>&limit=...&changedSince=...`
- `POST /v1/sessions` (create or load by `tag`)
- `GET /v1/sessions/:sessionId/messages`
- `DELETE /v1/sessions/:sessionId`
### Machines
- `POST /v1/machines` (create or load by id)
- `GET /v1/machines`
- `GET /v1/machines/:id`
### Artifacts
- `GET /v1/artifacts`
- `GET /v1/artifacts/:id`
- `POST /v1/artifacts`
- `POST /v1/artifacts/:id` (versioned update)
- `DELETE /v1/artifacts/:id`
### Access keys
- `GET /v1/access-keys/:sessionId/:machineId`
- `POST /v1/access-keys/:sessionId/:machineId`
- `PUT /v1/access-keys/:sessionId/:machineId`
### Key-value store
- `GET /v1/kv/:key`
- `GET /v1/kv?prefix=...&limit=...`
- `POST /v1/kv/bulk`
- `POST /v1/kv` (batch mutate)
### Account and usage
- `GET /v1/account/profile`
- `GET /v1/account/settings`
- `POST /v1/account/settings`
- `POST /v1/usage/query`
### Push tokens
- `POST /v1/push-tokens`
- `DELETE /v1/push-tokens/:token`
- `GET /v1/push-tokens`
### Connect (GitHub + vendor tokens)
- `GET /v1/connect/github/params`
- `GET /v1/connect/github/callback`
- `POST /v1/connect/github/webhook`
- `DELETE /v1/connect/github`
- `POST /v1/connect/:vendor/register` (`vendor` in `openai | anthropic | gemini`)
- `GET /v1/connect/:vendor/token`
- `DELETE /v1/connect/:vendor`
- `GET /v1/connect/tokens`
### Users, friends, feed
- `GET /v1/user/:id`
- `GET /v1/user/search?query=...`
- `POST /v1/friends/add`
- `POST /v1/friends/remove`
- `GET /v1/friends`
- `GET /v1/feed`
### Version and voice
- `POST /v1/version`
- `POST /v1/voice/token`
### Dev-only
- `POST /logs-combined-from-cli-and-mobile-for-simple-ai-debugging` (only if enabled)
## Implementation references
- API routes: `packages/happy-server/sources/app/api/routes`
- Auth module: `packages/happy-server/sources/app/auth/auth.ts`
+399
View File
@@ -0,0 +1,399 @@
# Backend Architecture
This document describes the Happy backend structure as implemented in `packages/happy-server`. It focuses on how the server is wired, how data flows through the system, and which subsystems handle which responsibilities.
## System overview
```mermaid
graph TB
subgraph Clients
CLI[CLI Client]
Mobile[Mobile App]
Daemon[Machine Daemon]
end
subgraph "Happy Server"
API[Fastify API]
Socket[Socket.IO]
Events[Event Router]
end
subgraph Storage
PG[(Postgres)]
Redis[(Redis)]
S3[(S3/MinIO)]
end
CLI --> API
Mobile --> API
Daemon --> API
CLI --> Socket
Mobile --> Socket
Daemon --> Socket
API --> PG
API --> S3
Socket --> Events
Events --> Redis
Events --> PG
```
## At a glance
- Runtime: Node.js + Fastify for HTTP, Socket.IO for realtime.
- Database: Postgres via Prisma.
- Cache/bus: Redis client is initialized (currently only pinged).
- Blob storage: S3-compatible (MinIO) for uploaded assets.
- Crypto: privacy-kit for auth tokens and encrypted service tokens.
- Metrics: Prometheus-style `/metrics` server + per-request HTTP metrics.
## Process lifecycle
Entry point: `packages/happy-server/sources/main.ts`.
```mermaid
flowchart TD
Start([main.ts]) --> DB[Connect Postgres]
DB --> Cache[Init Activity Cache]
Cache --> Redis[Redis ping]
Redis --> Crypto[Init Crypto Modules]
subgraph Crypto Initialization
Crypto --> Encrypt[initEncrypt - KeyTree]
Crypto --> GitHub[initGithub - OAuth/Webhooks]
Crypto --> S3[loadFiles - S3 Bucket]
Crypto --> Auth[auth.init - Token Gen]
end
Encrypt & GitHub & S3 & Auth --> Servers[Start Servers]
subgraph Server Startup
Servers --> API[API Server]
Servers --> Metrics[Metrics Server]
Servers --> DBMetrics[DB Metrics Updater]
Servers --> Presence[Presence Timeout Loop]
end
API & Metrics & DBMetrics & Presence --> Running([Running])
Running --> |SIGTERM| Shutdown[Shutdown Hooks]
Shutdown --> DBDisconnect[DB Disconnect]
Shutdown --> FlushCache[Flush Activity Cache]
```
Startup sequence:
1. Connect Postgres (`db.$connect()`).
2. Init activity cache (presence) and Redis connection check (`redis.ping()`).
3. Initialize crypto modules:
- `initEncrypt()` derives a KeyTree from `HANDY_MASTER_SECRET`.
- `initGithub()` configures GitHub App/webhooks if env vars exist.
- `loadFiles()` verifies S3 bucket access.
- `auth.init()` prepares token generator/verifier.
4. Start API server (`startApi()`), metrics server, database metrics updater, and presence timeout loop.
5. Remain alive until shutdown signal.
Shutdown hooks are registered for DB disconnect and activity-cache flush.
## API layer
`startApi()` in `sources/app/api/api.ts` wires the HTTP server:
- Fastify instance with Zod validators/serializers.
- Global hooks for monitoring and error handling.
- `authenticate` decorator that verifies Bearer tokens.
- Route modules under `sources/app/api/routes`.
- Socket.IO server attached at `/v1/updates`.
```mermaid
graph LR
subgraph "Fastify Server"
Hooks[Global Hooks]
Auth[authenticate decorator]
subgraph Routes
direction TB
R1[authRoutes]
R2[sessionRoutes]
R3[machinesRoutes]
R4[artifactsRoutes]
R5[accessKeysRoutes]
R6[kvRoutes]
R7[accountRoutes]
R8[userRoutes / feedRoutes]
R9[pushRoutes]
R10[connectRoutes / voiceRoutes]
end
end
SocketIO[Socket.IO /v1/updates]
Client --> Hooks --> Auth --> Routes
Client --> SocketIO
```
HTTP routes are organized by domain:
- Auth (`authRoutes`)
- Sessions + messages (`sessionRoutes`)
- Machines (`machinesRoutes`)
- Artifacts (`artifactsRoutes`)
- Access keys (`accessKeysRoutes`)
- Key-value store (`kvRoutes`)
- Account + usage (`accountRoutes`)
- Social + feed (`userRoutes`, `feedRoutes`)
- Push tokens (`pushRoutes`)
- Integrations (`connectRoutes`, `voiceRoutes`)
- Version checks (`versionRoutes`)
- Dev-only logging (`devRoutes`)
## Authentication and tokens
```mermaid
sequenceDiagram
participant Client
participant Server
participant DB as Postgres
participant Cache as Token Cache
Client->>Server: POST /v1/auth (signed challenge + public key)
Server->>DB: Upsert account by public key
DB-->>Server: Account record
Server->>Server: Generate Bearer token (privacy-kit)
Server->>Cache: Cache token
Server-->>Client: Bearer token
Note over Client,Cache: Subsequent requests
Client->>Server: Request + Bearer token
Server->>Cache: Verify token
Cache-->>Server: Valid / Account ID
Server-->>Client: Response
```
The backend does not store passwords. Instead:
- Clients authenticate with a signed challenge (`/v1/auth`) using a public key.
- The server upserts the account by public key and returns a Bearer token.
- Tokens are generated and verified by privacy-kit using `HANDY_MASTER_SECRET`.
- Tokens are cached in-memory for fast verification.
GitHub OAuth uses short-lived "ephemeral" tokens to protect the callback and is separate from normal auth.
## Realtime sync architecture
```mermaid
graph TB
subgraph Connections
U1[User Client 1]
U2[User Client 2]
S1[Session Client]
M1[Machine Daemon]
end
subgraph "Socket.IO Server"
Router[Event Router]
subgraph Scopes
US[user-scoped]
SS[session-scoped]
MS[machine-scoped]
end
end
U1 & U2 --> US
S1 --> SS
M1 --> MS
US & SS & MS --> Router
Router --> |persistent update| DB[(Postgres)]
Router --> |ephemeral event| Clients((Filtered Recipients))
```
### Connection types
Socket.IO connections are tagged by scope:
- `user-scoped`: receive all user updates.
- `session-scoped`: receive updates only for one session.
- `machine-scoped`: daemon connections for machine state.
### Event router
`EventRouter` (`sources/app/events/eventRouter.ts`) maintains per-user connection sets and routes:
- **Persistent `update` events**: database-backed changes with a user-level monotonic `seq`.
- **Ephemeral events**: presence/usage signals that are not persisted.
The router implements recipient filters so updates go only to interested connections (e.g., all session listeners or a specific machine).
### Update sequence numbers
- `Account.seq` is the per-user update counter. It is incremented by `allocateUserSeq` and used as `UpdatePayload.seq`.
- Sessions and artifacts maintain their own `seq` for per-object ordering.
## Presence and activity
```mermaid
flowchart LR
subgraph "High Frequency"
Events[session-alive / machine-alive]
Cache[Activity Cache]
end
subgraph "Batched Writes"
Batch[Batch Processor]
DB[(Postgres)]
end
subgraph "Timeout Loop"
Timer[10 min timer]
Offline[Mark Inactive]
Emit[Emit offline update]
end
Events --> |debounce| Cache
Cache --> |batch| Batch --> DB
Timer --> Cache
Cache --> |stale entries| Offline --> DB
Offline --> Emit
```
Presence is handled in `sources/app/presence`:
- `session-alive` and `machine-alive` events are debounced in memory (ActivityCache).
- Database writes are batched to reduce write load.
- A timeout loop marks sessions/machines inactive after 10 minutes of silence and emits an offline ephemeral update.
This splits high-frequency presence from durable storage updates.
## Storage and persistence
### Database (Prisma)
Prisma models live in `prisma/schema.prisma`. Key tables:
```mermaid
erDiagram
Account ||--o{ Session : owns
Account ||--o{ Machine : owns
Account ||--o{ Artifact : owns
Account ||--o{ UserKVStore : owns
Account ||--o{ UsageReport : tracks
Account ||--o{ UserRelationship : has
Account ||--o{ UserFeedItem : receives
Session ||--o{ SessionMessage : contains
Session ||--o{ AccessKey : grants
Machine ||--o{ AccessKey : receives
Account {
string publicKey
string profile
int seq
}
Session {
string metadata
int seq
}
Machine {
string metadata
string daemonState
}
Artifact {
string header
bytes body
string key
}
```
- `Account`: public key identity, profile, settings, seq counters.
- `Session` + `SessionMessage`: encrypted session metadata and message blobs.
- `Machine`: encrypted machine metadata + daemon state.
- `Artifact`: encrypted header/body + per-artifact key.
- `AccessKey`: encrypted per-session-per-machine access keys.
- `UserKVStore`: encrypted values with optimistic versions.
- `UsageReport`: usage aggregation per session/key.
- `UserRelationship` + `UserFeedItem`: social graph and feed.
### Transactions and retries
```mermaid
flowchart TD
Start([inTx call]) --> Begin[Begin Transaction]
Begin --> |Serializable| Exec[Execute Operations]
Exec --> Commit{Commit}
Commit --> |Success| After[afterTx callbacks]
After --> Emit[Emit Socket Updates]
Emit --> Done([Complete])
Commit --> |P2034 Error| Retry{Retry?}
Retry --> |Yes| Begin
Retry --> |Max retries| Fail([Throw Error])
```
`inTx()` wraps Prisma transactions with:
- Serializable isolation.
- Automatic retry on `P2034` (serialization failures).
- `afterTx()` to emit socket updates after commit.
This pattern is used for multi-write operations like batch KV mutation and session deletion.
### Blob storage (S3/MinIO)
The server uses S3-compatible storage for user assets (e.g., avatars):
- `storage/files.ts` configures the S3 client.
- `uploadImage` processes and stores files and writes metadata to `UploadedFile`.
- Public URLs are derived from `S3_PUBLIC_URL`.
### Redis
A Redis client is initialized in `main.ts` and pinged at startup. It can be expanded for caching or pub/sub if needed.
## Data confidentiality model
```mermaid
graph TB
subgraph "Client-side Encryption"
C1[Session metadata]
C2[Agent state]
C3[Daemon state]
C4[Message content]
C5[Artifacts]
C6[KV values]
end
subgraph "Server-side Encryption"
S1[GitHub OAuth tokens]
S2[OpenAI tokens]
S3[Anthropic tokens]
S4[Gemini tokens]
end
C1 & C2 & C3 & C4 & C5 & C6 --> |opaque blobs| DB[(Postgres)]
S1 & S2 & S3 & S4 --> |KeyTree from HANDY_MASTER_SECRET| DB
style C1 fill:#e1f5fe
style C2 fill:#e1f5fe
style C3 fill:#e1f5fe
style C4 fill:#e1f5fe
style C5 fill:#e1f5fe
style C6 fill:#e1f5fe
style S1 fill:#fff3e0
style S2 fill:#fff3e0
style S3 fill:#fff3e0
style S4 fill:#fff3e0
```
- Session metadata, agent state, daemon state, and message content are stored as opaque encrypted strings or blobs.
- Artifacts and KV values are stored encrypted and encoded as base64 on the wire.
- The server only encrypts/decrypts **service tokens** (GitHub OAuth tokens, vendor tokens) using the KeyTree derived from `HANDY_MASTER_SECRET`.
## Integrations
- **GitHub**: OAuth connect + webhook verification, optional if env vars are set.
- **AI vendors**: encrypted token storage for `openai`, `anthropic`, `gemini`.
- **Voice**: RevenueCat subscription check + ElevenLabs token minting.
- **Push tokens**: stored for later notification delivery.
## Observability
- `/health` route checks DB connectivity.
- Metrics server exposes `/metrics` for Prometheus.
- HTTP request counters and duration histograms are captured via Fastify hooks.
- WebSocket event counters and connection gauges are in `metrics2.ts`.
## Key implementation references
- Entrypoint: `packages/happy-server/sources/main.ts`
- API server: `packages/happy-server/sources/app/api/api.ts`
- Socket server: `packages/happy-server/sources/app/api/socket.ts`
- Event routing: `packages/happy-server/sources/app/events/eventRouter.ts`
- Presence: `packages/happy-server/sources/app/presence`
- Storage: `packages/happy-server/sources/storage`
- Prisma schema: `packages/happy-server/prisma/schema.prisma`
+381
View File
@@ -0,0 +1,381 @@
# CLI Architecture
This document describes the Happy CLI (`packages/happy-cli`) and its daemon. The CLI is both an interactive tool and a background session manager that keeps machine state in sync with the server.
## System overview
```mermaid
graph TB
subgraph "Happy CLI"
Entry[src/index.ts]
API[API Client]
Daemon[Daemon Process]
Agents[Agent Runners]
Persist[Persistence]
end
subgraph "~/.happy"
Settings[settings.json]
AccessKey[access.key]
DaemonState[daemon.state.json]
Logs[logs/]
end
subgraph Server
HTTP[HTTP API]
Socket[Socket.IO]
end
Entry --> API
Entry --> Daemon
Entry --> Agents
Entry --> Persist
Persist --> Settings & AccessKey & DaemonState & Logs
API --> HTTP & Socket
Daemon --> API
Agents --> API
```
## High-level layout
- **Entry point:** `src/index.ts` parses subcommands and routes execution.
- **API client:** `src/api` handles HTTP + Socket.IO, encryption, and RPC.
- **Daemon:** `src/daemon` runs in the background, spawns sessions, and maintains machine state.
- **Persistence/config:** `src/persistence.ts` + `src/configuration.ts` manage local state in `~/.happy`.
- **Agents:** `src/claude`, `src/codex`, `src/gemini` provide provider-specific runners.
## CLI entry flow
```mermaid
flowchart TD
Start([happy ...]) --> Parse[Parse subcommand]
Parse --> Doctor{doctor?}
Parse --> Auth{auth?}
Parse --> Connect{connect?}
Parse --> Agent{codex/gemini?}
Parse --> Default{default}
Doctor --> RunDoctor[Run diagnostics]
Auth --> RunAuth[Auth flow]
Connect --> RunConnect[Connect machine]
Agent --> Setup[authAndSetupMachineIfNeeded]
Default --> Setup
Setup --> Context{Background?}
Context --> |Yes| StartDaemon[Start daemon]
Context --> |No| RunAgent[Run agent directly]
StartDaemon --> SpawnSession[Spawn session]
```
`src/index.ts` is the CLI router. It:
- Parses subcommands (`doctor`, `auth`, `connect`, `codex`, `gemini`, and default run flows).
- Ensures auth and machine setup when needed (`authAndSetupMachineIfNeeded`).
- Starts the daemon or runs an agent directly based on subcommand/context.
## Local state and configuration
```mermaid
graph LR
subgraph "~/.happy"
direction TB
settings["settings.json<br/><i>profile, onboarding</i>"]
access["access.key<br/><i>encryption keys</i>"]
daemon["daemon.state.json<br/><i>PID, port, version</i>"]
logs["logs/<br/><i>CLI/daemon logs</i>"]
end
subgraph "Environment Overrides"
direction TB
E1[HAPPY_HOME_DIR]
E2[HAPPY_SERVER_URL]
E3[HAPPY_WEBAPP_URL]
E4[HAPPY_VARIANT]
E5[HAPPY_EXPERIMENTAL]
E6[HAPPY_DISABLE_CAFFEINATE]
end
E1 -.-> settings & access & daemon & logs
```
Local state lives under `~/.happy` (or `HAPPY_HOME_DIR`):
- `settings.json`: onboarding and profile settings (validated/migrated).
- `access.key`: local key material for encryption/auth.
- `daemon.state.json`: daemon PID + control port + version.
- `logs/`: CLI/daemon logs.
Configuration lives in `src/configuration.ts`:
- `HAPPY_SERVER_URL` and `HAPPY_WEBAPP_URL` override defaults.
- `HAPPY_VARIANT`, `HAPPY_EXPERIMENTAL`, `HAPPY_DISABLE_CAFFEINATE` control behavior.
## API client architecture
```mermaid
graph TB
subgraph "API Clients"
Base[ApiClient]
Session[ApiSessionClient]
Machine[ApiMachineClient]
Encrypt[encryption.ts]
end
subgraph "Server"
HTTP[HTTP API]
Socket[Socket.IO]
end
Base --> |POST /v1/sessions| HTTP
Base --> |POST /v1/machines| HTTP
Session --> |session-scoped| Socket
Machine --> |machine-scoped| Socket
Encrypt --> Base & Session & Machine
```
### HTTP
`ApiClient` (`src/api/api.ts`) handles:
- Session creation (`POST /v1/sessions`) with encrypted metadata/state.
- Machine registration (`POST /v1/machines`) with encrypted metadata/daemon state.
- Other CRUD actions through `ApiSessionClient` and `ApiMachineClient`.
### WebSocket
```mermaid
graph LR
subgraph "ApiSessionClient"
S_In[Receive: update]
S_Out[Emit: message, update-metadata,<br/>update-state, session-alive, usage-report]
end
subgraph "ApiMachineClient"
M_In[Receive: machine updates]
M_Out[Emit: machine-alive,<br/>update metadata/state]
end
Server((Socket.IO)) --> S_In & M_In
S_Out & M_Out --> Server
```
`ApiSessionClient` (`src/api/apiSession.ts`) connects to Socket.IO as a **session-scoped** client:
- Receives `update` events and decrypts message content.
- Emits `message`, `update-metadata`, `update-state`, `session-alive`, and `usage-report`.
`ApiMachineClient` (`src/api/apiMachine.ts`) connects as a **machine-scoped** client:
- Sends `machine-alive` heartbeats.
- Updates machine metadata/daemon state with optimistic concurrency.
- Receives machine updates and merges them locally.
### Encryption
```mermaid
flowchart LR
subgraph "Client-side"
Plain[Plaintext Data]
Encrypt[encryption.ts]
B64[Base64 Encoded]
end
Plain --> |encrypt| Encrypt --> B64 --> |send| Server[(Server)]
Server --> |receive| B64 --> |decrypt| Encrypt --> Plain
style Plain fill:#e8f5e9
style B64 fill:#fff3e0
```
The CLI encrypts client content before it leaves the machine using `src/api/encryption.ts`.
- Session metadata, agent state, messages, machine state, artifacts, and KV values are encrypted client-side.
- On-wire encoding is base64; see `encryption.md`.
## Daemon architecture
```mermaid
graph TB
subgraph "Daemon Process"
Control[Control Server<br/>127.0.0.1:port]
Sessions[Session Map]
MachineClient[ApiMachineClient]
end
subgraph "Child Processes"
S1[Session 1]
S2[Session 2]
S3[Session N]
end
CLI[CLI] --> |IPC| Control
Control --> Sessions
Sessions --> S1 & S2 & S3
MachineClient --> |heartbeat| Server[(Server)]
MachineClient --> |state sync| Server
```
The daemon is a long-lived process responsible for running sessions in the background and maintaining machine presence.
### Lifecycle
```mermaid
flowchart TD
Start([startDaemon]) --> Validate[Validate version]
Validate --> Lock[Acquire lock file]
Lock --> Auth[Authenticate]
Auth --> Register[Register machine with server]
Register --> Control[Start control server]
Control --> Track[Track child sessions]
Track --> Sync[Sync daemon state to server]
Sync --> Running([Running])
Running --> |SIGTERM| Shutdown[Cleanup & exit]
```
1. `startDaemon()` validates the running version and acquires a lock file.
2. It authenticates and registers the machine with the server.
3. It starts a local **control server** for IPC.
4. It keeps a map of tracked child sessions and updates daemon state on the server.
### Control server (local IPC)
```mermaid
sequenceDiagram
participant CLI
participant State as daemon.state.json
participant Control as Control Server
participant Daemon
CLI->>State: Read port
State-->>CLI: port: 12345
CLI->>Control: GET /list
Control-->>CLI: [sessions...]
CLI->>Control: POST /spawn-session
Control->>Daemon: Spawn child process
Daemon-->>Control: Session started
Control-->>CLI: OK
CLI->>Control: POST /stop
Control->>Daemon: Shutdown
```
`startDaemonControlServer()` (`src/daemon/controlServer.ts`) runs an HTTP server on `127.0.0.1` and exposes:
- `/list` (list active sessions)
- `/stop-session`
- `/spawn-session`
- `/stop` (shutdown daemon)
- `/session-started` (session self-report)
The CLI talks to this server via `controlClient.ts`, using a port stored in `daemon.state.json`.
### Session spawning
```mermaid
flowchart LR
subgraph "Session Sources"
CLI[CLI<br/><i>foreground</i>]
Daemon[Daemon<br/><i>background</i>]
Remote[Mobile/Web<br/><i>via RPC</i>]
end
subgraph "Session Process"
Session[Agent Session]
Handlers[RPC Handlers]
end
CLI --> Session
Daemon --> Session
Remote --> |spawn-session| Daemon --> Session
Session --> Handlers
subgraph "RPC Surface"
Handlers --> Bash[bash]
Handlers --> Files[file read/write]
Handlers --> Search[ripgrep]
Handlers --> Diff[difftastic]
end
```
Sessions can be started by:
- The CLI directly (foreground).
- The daemon (background).
- Remote requests over RPC (from mobile/web via machine connection).
Daemon session spawning uses `registerCommonHandlers` to expose a controlled RPC surface (shell commands, file operations, search/diff helpers).
### Machine state
```mermaid
graph TB
subgraph "Machine Metadata (static)"
M1[host]
M2[platform]
M3[CLI version]
M4[paths]
end
subgraph "Daemon State (dynamic)"
D1[pid]
D2[httpPort]
D3[startedAt]
D4[shutdown info]
end
subgraph "Sync Targets"
Server[(Server)]
Local[daemon.state.json]
end
ApiMachine[ApiMachineClient]
M1 & M2 & M3 & M4 --> ApiMachine
D1 & D2 & D3 & D4 --> ApiMachine
D1 & D2 & D3 & D4 --> Local
ApiMachine --> Server
```
- **Machine metadata** is static info (host, platform, CLI version, paths).
- **Daemon state** is dynamic (pid, httpPort, startedAt, shutdown info).
The daemon updates these via `ApiMachineClient` and mirrors local state into `daemon.state.json` for control/diagnostics.
## RPC and tool bridge
```mermaid
sequenceDiagram
participant Mobile
participant Server
participant Daemon
participant Session
Mobile->>Server: RPC: spawn-session
Server->>Daemon: Forward via Socket.IO
Daemon->>Session: Spawn process
Session-->>Daemon: Running
Mobile->>Server: RPC: bash "ls -la"
Server->>Session: Forward via Socket.IO
Session->>Session: Execute command
Session-->>Server: Result
Server-->>Mobile: Result
Note over Mobile,Session: All RPC flows through Socket.IO<br/>No direct REST exposure
```
RPC is used to send commands over the Socket.IO connection:
- Sessions register RPC handlers (e.g., `bash`, file read/write, `ripgrep`, `difftastic`).
- The daemon registers a spawn-session handler so the server/mobile client can ask it to start a local session.
This mechanism allows the server and mobile clients to drive local actions without exposing a broad REST surface.
## Implementation references
- CLI entry: `packages/happy-cli/src/index.ts`
- Daemon: `packages/happy-cli/src/daemon`
- Control server/client: `packages/happy-cli/src/daemon/controlServer.ts`, `packages/happy-cli/src/daemon/controlClient.ts`
- API clients: `packages/happy-cli/src/api`
- Persistence: `packages/happy-cli/src/persistence.ts`
- Config: `packages/happy-cli/src/configuration.ts`
+86
View File
@@ -0,0 +1,86 @@
# Competition Research
Use this folder for distilled competitor research that is worth keeping in the
Happy repo.
## What belongs here
- markdown notes about how another product works
- protocol writeups, message samples, and sequence diagrams
- screenshots and small sanitized artifacts that explain behavior
- links to upstream docs, repos, commits, issues, and blog posts
- comparisons back to Happy when the finding affects product or protocol design
## What does not belong here
- git checkouts of competitor repos
- git submodules
- copied source trees or vendored code dumps
- large raw logs, binaries, or secrets
If you need a checkout for research, keep it outside this repository. Prefer the
existing adjacent area under `../happy-adjacent/research/{vendor}` when it
exists; otherwise use another machine-local path that is not committed.
## Recommended layout
```text
docs/competition/
├── AGENTS.md
├── comparison-matrix.md # cross-vendor summary by topic
├── claude/
│ ├── README.md # high-level overview and key takeaways
│ ├── sources.md # upstream URLs, commit hashes, dates reviewed
│ ├── message-protocol.md # envelopes, streaming events, turn boundaries
│ ├── session-lifecycle.md # startup, resume, interruption, teardown
│ └── artifacts/ # screenshots, tiny trace snippets, diagrams
├── codex/
│ └── ...
└── opencode/
└── ...
```
## Per-vendor file expectations
Each vendor folder should start small and stay focused:
- `README.md`: what this product is, what was inspected, and the main findings
- `sources.md`: repo URL, docs links, commit/tag reviewed, and review date
- topic files such as `message-protocol.md`, `tool-calling.md`,
`subagents.md`, `task-tracking.md`, `modes-and-permissions.md`, or
`sandbox.md` when those topics matter
- `artifacts/`: only small evidence files that help explain the writeup
Do not mirror the competitor's repo layout here. Write the conclusions we want
to keep.
## Research workflow
1. Inspect the competitor from a local checkout, docs site, product behavior, or
captured traces.
2. Record the exact upstream references in `sources.md`.
3. Write the distilled result in the vendor folder.
4. Extract reusable comparisons into `comparison-matrix.md` when multiple
vendors cover the same topic.
## Current priorities
Start with the protocol and control surfaces that matter most for Happy:
- message protocol and event envelopes
- tool call representation and streaming
- subagents / task delegation model
- task tracking / todo surfaces
- mode switching and model switching
- permission / approval flow
- sandbox / workspace isolation
- session resume, fork, and interrupt behavior
- remote sync / server architecture
Current product note: OpenCode is a particularly strong reference right now.
Its desktop UI, feature set, and especially the clickable context/debug surface
look worth studying closely. Treat its messaging protocol as a leading design
input, and dig further into how it syncs state with its server.
The rule of thumb is simple: checkouts stay outside the repo; insights and small
supporting artifacts go here.
+39
View File
@@ -0,0 +1,39 @@
# Claude Code
Reviewed on 2026-03-20 from these main sources:
- `../happy-adjacent/research/claude-code` at `6aadfbdca2c29f498f579509a56000e4e8daaf90`
- `../happy-adjacent/research/claude-code-acp` at `521d1f766d421f8d21d162e1c799edc094781dfc`
- `../happy-adjacent/research/agent-client-protocol` at `cd10d9b86e04caaf05bd5e75d860da4c17fcd2f8`
- local `~/.claude/` state
- `docs/research/agent-teams-claude-code.md`
## Why it matters
Claude Code is the strongest workflow reference for agent teams and local agent
state, but it is not one clean protocol surface.
- ACP is reasonably clean
- hooks expose a typed event interface
- agent teams are powerful
- the richest state still leaks into `~/.claude/` files
## Current take
- Claude is a great source of ideas for agent teams, subagent identity, and permission/mode control.
- Claude is a worse reference for a single canonical session protocol than OpenCode or Codex.
- If Happy borrows from Claude, it should borrow product behavior and workflow ideas, not the hidden local-state dependency.
## Important sources
- `../happy-adjacent/research/claude-code/CHANGELOG.md`
- `../happy-adjacent/research/claude-code/plugins/plugin-dev/skills/hook-development/SKILL.md`
- `../happy-adjacent/research/claude-code/plugins/plugin-dev/skills/agent-development/SKILL.md`
- `../happy-adjacent/research/claude-code/examples/settings/README.md`
- `../happy-adjacent/research/claude-code-acp/src/acp-agent.ts`
- `../happy-adjacent/research/agent-client-protocol/src/agent.rs`
- `../happy-adjacent/research/agent-client-protocol/src/client.rs`
- `docs/research/agent-teams-claude-code.md`
See `docs/competition/claude/message-protocol.md` and
`docs/competition/claude/sources.md`.
+145
View File
@@ -0,0 +1,145 @@
# Claude Code Protocol and Control Surface
## Bottom line
Claude Code is not one protocol. It is several layers:
- ACP for clean client/agent session control
- hook JSON for event interception and policy
- local `~/.claude/` files for rich team and subagent state
- product behavior documented partly in changelog and settings examples
That makes it powerful, but harder to copy cleanly.
## ACP session protocol
ACP is the cleanest part of the Claude stack.
- ACP is JSON-RPC
- sessions stream updates through `session/update`
- updates include user chunks, agent chunks, thoughts, tool calls, tool call updates, plans, current mode updates, config option updates, and session info
- prompt execution, cancel, load, resume, fork, close, and list are all explicit protocol operations
Primary source files:
- `../happy-adjacent/research/agent-client-protocol/src/agent.rs`
- `../happy-adjacent/research/agent-client-protocol/src/client.rs`
- `../happy-adjacent/research/agent-client-protocol/src/tool_call.rs`
## Claude ACP adapter behavior
The Claude ACP adapter maps Claude Code behavior into ACP.
- permission modes such as `default`, `acceptEdits`, `plan`, `dontAsk`, and `bypassPermissions` are surfaced through ACP-facing controls
- mode and model configuration are emitted as config options and current-mode updates
- additional workspace scope is passed through `_meta.additionalRoots`
- session create, load, resume, replay, and fork are implemented in the adapter layer
This is important for Happy because it shows where clean protocol stops and provider-specific behavior begins.
Primary source files:
- `../happy-adjacent/research/claude-code-acp/src/acp-agent.ts`
- `../happy-adjacent/research/claude-code-acp/src/settings.ts`
## Hook/event protocol
Claude has a separate typed event surface for hooks.
- hook input includes `session_id`, `transcript_path`, `cwd`, `permission_mode`, and `hook_event_name`
- hook events include `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStop`, `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `PreCompact`, and `Notification`
- changelog notes add additional events such as `PermissionRequest`, `SubagentStart`, `TeammateIdle`, and `TaskCompleted`
- hook outputs can allow, deny, ask, suppress output, or inject system messages
This is one of the best pieces of Claude's design: event interception is explicit.
Primary source files:
- `../happy-adjacent/research/claude-code/plugins/plugin-dev/skills/hook-development/SKILL.md`
- `../happy-adjacent/research/claude-code/CHANGELOG.md`
## Subagents and task tracking
Claude is strongest here at the product level, but the state lives in several places.
- custom agents are markdown-defined with frontmatter such as `name`, `description`, `model`, `color`, and optional tool restrictions
- the `Task` tool launches or communicates with agents
- local team state lives under `~/.claude/teams/`
- local task queue state lives under `~/.claude/tasks/`
- subagent conversation chains live under `~/.claude/projects/.../subagents/`
The main lesson for Happy is not to copy the hidden-file layout. The lesson is to
keep agent identity, team membership, and task lifecycle explicit.
Primary source files:
- `../happy-adjacent/research/claude-code/plugins/plugin-dev/skills/agent-development/SKILL.md`
- `docs/research/agent-teams-claude-code.md`
- `~/.claude/teams/`
- `~/.claude/tasks/`
## Permissions and mode switching
Claude treats this as real state, not a prompt-only convention.
- settings files define ask/deny policy and whether bypass mode is allowed
- `PreToolUse` hooks can make permission decisions
- dedicated `PermissionRequest` hooks can also approve or deny
- plan mode is a real runtime mode, not just different wording
- custom agents can carry their own permission mode
This is a strong pattern for Happy: mode and permission state should be first-class and inspectable.
Primary source files:
- `../happy-adjacent/research/claude-code/examples/settings/settings-strict.json`
- `../happy-adjacent/research/claude-code/plugins/plugin-dev/skills/hook-development/SKILL.md`
- `../happy-adjacent/research/claude-code/CHANGELOG.md`
## Sandbox and workspace controls
Claude's safety story is layered.
- shell sandboxing is focused mainly on `Bash`
- settings include network allowlists, command exclusions, and nested sandbox behavior
- additional read/write controls and protected directories exist
- workspace trust is a separate gate from sandboxing
This is less unified than Codex's sandbox policy, but still better than pretending all tool safety is the same thing.
Primary source files:
- `../happy-adjacent/research/claude-code/examples/settings/README.md`
- `../happy-adjacent/research/claude-code/examples/settings/settings-bash-sandbox.json`
- `../happy-adjacent/research/claude-code/CHANGELOG.md`
## Resume, fork, and lifecycle
Claude clearly treats session lifecycle as a product priority.
- session start/end and compaction have hook events
- resume and continue have many changelog fixes around transcript restoration and tool-result replay
- fork was renamed to branch and needed isolation fixes
- sessions support naming and named resume
- local per-session state is often keyed by `session_id`
This is a reminder for Happy that resume correctness is not a small detail; it is a protocol feature.
## Remote and sync implications
Claude is the weakest clean reference here.
- ACP is promising for remote control and agent interoperability
- there is a remote-control bridge to `claude.ai/code`
- MCP networking is well-documented
- but the richest team and subagent state still lives in local files under `~/.claude/`
So Claude is useful as a workflow reference, but not the best single source for Happy's own sync protocol.
## What Happy should steal
- first-class mode and permission state
- typed event interception around tools and lifecycle
- strong subagent identity and task lifecycle concepts
- explicit resume/fork semantics
- do not copy the dependency on hidden local files as the main state model
+44
View File
@@ -0,0 +1,44 @@
# Claude Code Sources
Reviewed on 2026-03-20.
## Primary repos
- repo: `https://github.com/anthropics/claude-code`
- checkout: `../happy-adjacent/research/claude-code`
- commit: `6aadfbdca2c29f498f579509a56000e4e8daaf90`
- repo: `https://github.com/zed-industries/claude-code-acp`
- checkout: `../happy-adjacent/research/claude-code-acp`
- commit: `521d1f766d421f8d21d162e1c799edc094781dfc`
- repo: `https://github.com/agentclientprotocol/agent-client-protocol`
- checkout: `../happy-adjacent/research/agent-client-protocol`
- commit: `cd10d9b86e04caaf05bd5e75d860da4c17fcd2f8`
## Local sources
- `~/.claude/teams/`
- `~/.claude/tasks/`
- `~/.claude/projects/`
- `docs/research/agent-teams-claude-code.md`
## Primary files inspected
- `../happy-adjacent/research/claude-code/CHANGELOG.md`
- `../happy-adjacent/research/claude-code/plugins/plugin-dev/skills/hook-development/SKILL.md`
- `../happy-adjacent/research/claude-code/plugins/plugin-dev/skills/agent-development/SKILL.md`
- `../happy-adjacent/research/claude-code/examples/settings/README.md`
- `../happy-adjacent/research/claude-code/examples/settings/settings-bash-sandbox.json`
- `../happy-adjacent/research/claude-code/examples/settings/settings-strict.json`
- `../happy-adjacent/research/claude-code-acp/src/acp-agent.ts`
- `../happy-adjacent/research/claude-code-acp/src/settings.ts`
- `../happy-adjacent/research/agent-client-protocol/src/agent.rs`
- `../happy-adjacent/research/agent-client-protocol/src/client.rs`
- `../happy-adjacent/research/agent-client-protocol/src/tool_call.rs`
- `docs/research/agent-teams-claude-code.md`
## Notes
- Claude requires combining public repo docs, ACP adapter code, ACP spec, changelog notes, and local filesystem state.
- That split is itself an important product/protocol observation.
+32
View File
@@ -0,0 +1,32 @@
# Codex
Reviewed on 2026-03-20 from `../happy-adjacent/research/codex` at commit
`ec32866c379405a28b58c0064c857fb60ed3c735`.
## Why it matters
Codex is the strongest backend protocol reference in this set.
- typed app-server contract
- explicit `thread`, `turn`, and `item` model
- approvals are server requests, not ad hoc message events
- sandbox policy is much richer than OpenCode's
- resume, fork, and live replay are clearly first-class concerns
## Current take
- If Happy wants a server-side session protocol, Codex is the best reference.
- If Happy wants a UI/session transcript shape, OpenCode still feels stronger.
- The best outcome may be OpenCode-like transcript state with Codex-like approval and runtime semantics.
## Important repo files
- `../happy-adjacent/research/codex/codex-rs/app-server/README.md`
- `../happy-adjacent/research/codex/codex-rs/app-server-protocol/src/protocol/v2.rs`
- `../happy-adjacent/research/codex/codex-rs/app-server/src/lib.rs`
- `../happy-adjacent/research/codex/codex-rs/app-server/src/transport.rs`
- `../happy-adjacent/research/codex/codex-rs/app-server/src/thread_state.rs`
- `../happy-adjacent/research/codex/codex-rs/app-server/src/codex_message_processor.rs`
See `docs/competition/codex/message-protocol.md` and
`docs/competition/codex/sources.md`.
+123
View File
@@ -0,0 +1,123 @@
# Codex App-Server Protocol
## Bottom line
Codex has the strongest server protocol of the systems reviewed so far.
It is especially useful for approvals, runtime state, resume/fork, and sandbox
policy.
## Core transport and state model
Codex app-server speaks JSON-RPC 2.0 over stdio and an experimental websocket
transport.
- the core persistent model is `thread` -> `turn` -> `item`
- clients start, resume, or fork threads explicitly
- turns are started explicitly and stream item updates live
- `ThreadItem` is a tagged union, not an untyped blob
Primary source files:
- `../happy-adjacent/research/codex/codex-rs/app-server/README.md`
- `../happy-adjacent/research/codex/codex-rs/app-server-protocol/src/protocol/v2.rs`
## Transcript and live notifications
Codex leans heavily on typed notifications.
- notifications cover thread lifecycle, turn lifecycle, plan updates, deltas, approvals, and more
- important item families include `agentMessage`, `reasoning`, `commandExecution`, `fileChange`, `mcpToolCall`, `dynamicToolCall`, `collabAgentToolCall`, `webSearch`, and `contextCompaction`
- live streaming is done with dedicated delta notifications rather than a single text stream
- the README explicitly warns that initial thread or turn payloads may be sparse; live notifications are the canonical source of active state
This is a good reminder for Happy: a list endpoint and a stream endpoint should not be the same thing.
## Subagents and collaboration
Codex models subagents as typed items, not hidden side effects.
- collaboration agent activity appears as `CollabAgentToolCall`
- supported actions include spawn, send input, resume, wait, and close
- thread metadata can indicate subagent origin and carry agent nickname/role
- thread status and collab-agent state are explicit typed fields
This is a good template for representing delegated work inside Happy without losing identity.
Primary source files:
- `../happy-adjacent/research/codex/codex-rs/app-server-protocol/src/protocol/v2.rs`
## Approval model
Codex's approval model is one of the best things in the repo.
- approvals are not just notifications; the server sends explicit JSON-RPC requests to the client
- there are separate approval request shapes for command execution, file changes, permission changes, user input, and MCP elicitation
- the server later emits resolution notifications so UI state can clear correctly
- reviewer identity can be the user or a guardian subagent
Happy should copy this structure: normal event stream for state, explicit server requests for blocking decisions.
Primary source files:
- `../happy-adjacent/research/codex/codex-rs/app-server-protocol/src/protocol/common.rs`
- `../happy-adjacent/research/codex/codex-rs/app-server-protocol/src/protocol/v2.rs`
## Modes and model switching
Codex exposes this as structured protocol state.
- model/provider/service tier/effort/summary/personality can be set at thread or turn boundaries
- collaboration mode is a real protocol concept with its own list and selection surface
- model reroutes are surfaced as protocol events with reason fields
This is much better than hiding mode changes in prompt text or UI-only state.
## Sandbox policy
Codex clearly wins on sandbox expressiveness.
- coarse modes include read-only, workspace-write, and full access
- richer `SandboxPolicy` variants allow writable roots, read-only access, network access, and external sandbox options
- there are Windows-specific setup flows for sandbox support
- some commands are explicitly unsandboxed, which is documented rather than hidden
This is a strong reference for Happy's server-side permission and sandbox contract.
## Resume, fork, and lifecycle
Resume and fork are treated as first-class protocol paths.
- `thread/resume` supports several restore paths
- `thread/fork` supports persistent and ephemeral forks
- protocol has knobs such as `persist_extended_history`
- runtime live-watch state is separated from persisted thread history
- tests cover real edge cases like joining a running thread or replaying pending approvals on resume
This split between stored history and live watcher state is worth copying.
## Sync and transport robustness
Codex is more serious than the others about backpressure and client capability drift.
- websocket support has explicit health endpoints and origin restrictions
- bounded queues protect the server
- overloaded request paths return errors instead of hanging forever
- slow websocket clients can be disconnected cleanly
- notification filtering and experimental field gating exist per connection
Happy should take this seriously if it wants robust mobile or multi-client session control.
Primary source files:
- `../happy-adjacent/research/codex/codex-rs/app-server/src/lib.rs`
- `../happy-adjacent/research/codex/codex-rs/app-server/src/transport.rs`
- `../happy-adjacent/research/codex/codex-rs/app-server/src/thread_state.rs`
## What Happy should steal
- explicit `thread` / `turn` / `item` protocol model
- server-initiated approval requests
- typed collab-agent items for subagents
- real sandbox policy objects with network and path controls
- clear split between persisted history and runtime watcher state
+23
View File
@@ -0,0 +1,23 @@
# Codex Sources
Reviewed on 2026-03-20.
- repo: `https://github.com/openai/codex`
- checkout: `../happy-adjacent/research/codex`
- commit: `ec32866c379405a28b58c0064c857fb60ed3c735`
## Primary files inspected
- `../happy-adjacent/research/codex/codex-rs/app-server/README.md`
- `../happy-adjacent/research/codex/codex-rs/app-server-protocol/src/protocol/v2.rs`
- `../happy-adjacent/research/codex/codex-rs/app-server/src/lib.rs`
- `../happy-adjacent/research/codex/codex-rs/app-server/src/transport.rs`
- `../happy-adjacent/research/codex/codex-rs/app-server/src/thread_state.rs`
- `../happy-adjacent/research/codex/codex-rs/app-server/src/codex_message_processor.rs`
- `../happy-adjacent/research/codex/codex-rs/app-server/tests/suite/v2/thread_resume.rs`
- `../happy-adjacent/research/codex/codex-rs/app-server/tests/suite/v2/thread_fork.rs`
## Notes
- This is the best typed approval and sandbox protocol surface in the current comparison set.
- The app-server README is unusually useful and should be revisited as Happy's own protocol evolves.
+29
View File
@@ -0,0 +1,29 @@
# Competitor Protocol Matrix
Reviewed on 2026-03-20. Superset added 2026-04-08.
This is the short version of what matters most for Happy.
| Vendor | Core transport | Transcript shape | Subagents / tasks | Permissions | Sandbox story | Sync / remote story | Happy takeaway |
| --- | --- | --- | --- | --- | --- | --- | --- |
| OpenCode | HTTP + SSE | message envelope + typed parts | child sessions via `task` tool | first-class request objects + rules | worktree/workspace isolation, not OS sandbox | strong client/server split with event stream + fetch hydration | best overall product and protocol reference right now |
| Codex | JSON-RPC 2.0 over stdio or websocket | typed `thread` / `turn` / `item` graph | typed collab-agent items | explicit server requests for approvals | strongest real sandbox policy surface of the three | robust live subscription, replay, resume, overload handling | best backend protocol reference |
| Claude | ACP JSON-RPC plus local filesystem state | streamed session updates + local transcript files | agent teams, inboxes, tasks, subagent JSONL | mode + permission are first-class, but split across ACP and local settings | narrower shell sandbox plus trust / permission layers | local-first, remote-control bridge exists, rich state still leaks to disk | best agent workflow reference, weaker as a single clean protocol |
| Superset | Hono HTTP + tRPC + WebSocket EventBus | opaque — doesn't parse agent output, observes lifecycle via hooks | agent-agnostic launch in real PTY + git worktree isolation per task | not applicable — delegates to each agent's own permission model | worktree isolation, agents run in real terminals with their own sandboxing | Electric SQL cloud→local sync + cloud DB command queue for CLI→desktop control + manifest-based host-service durability | best orchestration-layer reference; strongest package boundaries; Electric SQL sync and cloud command queue patterns worth studying |
## Current read
- OpenCode is the most attractive end-to-end reference for Happy right now.
- Codex has the cleanest typed app-server model for thread, turn, item, approval, and sandbox policy.
- Claude has the most mature agent-team workflow, but its useful state is split across ACP, hooks, changelog behavior, and `~/.claude/`.
- Superset is the strongest reference for orchestration-layer design — doesn't own agent protocols, just coordinates. Remarkable ship velocity (3 people, 2,100+ commits in 5 months).
## Suggested design direction for Happy
- Use OpenCode's envelope + typed-parts transcript model as the main UI/session protocol reference.
- Steal Codex's explicit server-request pattern for approvals and user input.
- Keep Claude's agent-team lessons, but avoid depending on hidden local files as the primary source of truth.
- Treat todos, permissions, questions, and subagents as first-class state channels, not assistant-text hacks.
- Study OpenCode's server sync path next; that looks like the highest-leverage follow-up.
- Evaluate Superset's Electric SQL cloud→local sync pattern and cloud DB command queue as alternatives to Happy's current sync approach.
- Consider Superset's host-service extraction pattern (injectable providers, manifest-based durability) for Happy's CLI/server split.
+45
View File
@@ -0,0 +1,45 @@
# OpenCode
Reviewed on 2026-03-21 from `../happy-adjacent/research/opencode` at commit
`2e0d5d230893dbddcefb35a02f53ff2e7a58e5d0`.
## Why it matters
OpenCode is currently the strongest overall product reference for Happy.
- the desktop UI is excellent
- the feature set feels coherent and ambitious
- clicking the context field opens a genuinely useful debug/context inspector
- the transcript and sync model look much closer to what Happy should want than a flat custom message stream
## Current take
- Use OpenCode as the leading reference for transcript design.
- Strongly consider adopting its message-and-parts direction instead of inventing another bespoke message protocol.
- Keep digging into how it syncs state between client and server; there is a lot to learn there.
- Read `runtime-tracing.md` first if you want the real story. That file now has
concrete permission, media, subtask, and routing flows with actual payload
snippets from a source-run OpenCode server.
## Key findings
- transcript model: stable message envelopes with ordered typed parts
- live updates: incremental SSE-style event stream plus paged fetch hydration
- subagents: child sessions created through the `task` tool, resumable by `task_id`
- task tracking: first-class todo store, not buried in message text
- permissions: first-class request objects plus pattern rules and decision history
- sandbox: workspace/worktree isolation, not a strict OS sandbox
## Important repo files
- `../happy-adjacent/research/opencode/packages/opencode/src/session/message-v2.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/session/index.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/tool/task.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/tool/todo.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/permission/index.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/control-plane/workspace-server/routes.ts`
- `../happy-adjacent/research/opencode/packages/app/src/components/session/session-context-tab.tsx`
See `docs/competition/opencode/message-protocol.md`,
`docs/competition/opencode/runtime-tracing.md`, and
`docs/competition/opencode/sources.md`.
@@ -0,0 +1,149 @@
# OpenCode Message Protocol
## Bottom line
OpenCode has the cleanest transcript shape of the three systems reviewed so far.
If Happy wants a strong protocol reference for app + server + session UI, this is
the one to steal from first.
## Core transcript model
The key design is: message envelope first, typed parts second.
- messages are stable top-level records with IDs, session linkage, model/provider metadata, path, token usage, cost, and error state
- content is not a single blob; it is an ordered list of typed parts
- important part kinds include `text`, `reasoning`, `tool`, `file`, `snapshot`, `patch`, `agent`, `subtask`, `step-start`, `step-finish`, and `compaction`
- this makes streaming, partial updates, replay, and debug rendering much cleaner than mutating one giant assistant string
Primary source files:
- `../happy-adjacent/research/opencode/packages/opencode/src/session/message-v2.ts`
- `../happy-adjacent/research/opencode/packages/sdk/js/src/v2/gen/types.gen.ts`
## Live event model
OpenCode separates full transcript state from incremental transport events.
- canonical live events include `message.updated`, `message.part.updated`, `message.part.delta`, `message.part.removed`, `message.removed`, `session.status`, `todo.updated`, and `permission.asked`
- `message.part.delta` is the streaming primitive for appending text into a named field
- later `message.part.updated` events can replace or supersede earlier deltas
- the UI reducer explicitly merges stream events into cached transcript state
This is a very good pattern for Happy: use events for freshness, not as the only
source of truth.
Primary source files:
- `../happy-adjacent/research/opencode/packages/opencode/src/session/index.ts`
- `../happy-adjacent/research/opencode/packages/app/src/context/global-sync/event-reducer.ts`
## Subagents and task delegation
OpenCode models delegation as child sessions, not inline mystery behavior.
- the `task` tool chooses a non-primary agent and creates or resumes a child session
- `task_id` is really the child session ID, so delegation is resumable
- subagent intent is visible in the parent transcript as a tool call and related subtask part
- tool permissions for subagents are intentionally constrained
This is much closer to what Happy should want than flattening delegated work into
one chat thread without identity.
Primary source files:
- `../happy-adjacent/research/opencode/packages/opencode/src/tool/task.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/agent/agent.ts`
## Task tracking / todos
Todos are a first-class session store.
- todo state is separate from transcript parts
- write behavior is whole-list replacement with ordered rows
- schema is intentionally tiny: `content`, `status`, `priority`
- UI gets a proper todo dock instead of scraping plans from text
This is a strong design signal for Happy: todos should be their own state channel.
Primary source files:
- `../happy-adjacent/research/opencode/packages/opencode/src/tool/todo.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/session/todo.ts`
## Modes, models, and permissions
OpenCode treats these as explicit state, not just prompt flavor.
- newer logic keys on `agent`, `providerID`, `modelID`, and `variant`
- plan/build/general/explore are modeled as agent choices more than a free-form mode string
- permissions are first-class requests with `id`, `sessionID`, `permission`, patterns, metadata, and decision mode
- decisions can be `once`, `always`, or `reject`
- permission rules are pattern-based and support auto-unblocking pending matching requests
This is a good reference for Happy's app model even if Happy keeps its own policy
engine.
Primary source files:
- `../happy-adjacent/research/opencode/packages/opencode/src/permission/index.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/permission/evaluate.ts`
- `../happy-adjacent/research/opencode/packages/app/src/context/permission.tsx`
## Sandbox and isolation
OpenCode is weaker on true sandboxing than Codex.
- the main isolation story is workspace and git worktree separation
- extra workspaces are treated like sandboxes in product language
- the server routes operations by workspace directory
- there is not much evidence here of strong OS-level sandbox policy comparable to Codex
Takeaway for Happy: copy the workspace isolation ideas, not the lack of a deeper
sandbox layer.
Primary source files:
- `../happy-adjacent/research/opencode/packages/opencode/src/worktree/index.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/control-plane/workspace.ts`
## Sync and server architecture
This is probably the most valuable follow-up topic.
- the app listens to a global SSE stream
- events are then fanned into per-directory caches and reducers
- full session history is fetched separately when needed
- the client batches and coalesces updates instead of repainting on every raw event
- the control plane already looks ready for non-local workspace adaptors later
This is a much better direction for Happy than a single opaque message pipeline.
Primary source files:
- `../happy-adjacent/research/opencode/packages/opencode/src/control-plane/workspace-server/routes.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/control-plane/sse.ts`
- `../happy-adjacent/research/opencode/packages/app/src/context/global-sync.tsx`
- `../happy-adjacent/research/opencode/packages/app/src/context/sync.tsx`
## Context debug surface
The user feedback here is correct and important.
- clicking the context usage field opens a context tab
- that tab shows a useful breakdown of model/provider/context usage
- it also exposes raw message-plus-parts state, effectively a built-in protocol debugger
Happy should copy this idea.
Primary source files:
- `../happy-adjacent/research/opencode/packages/app/src/components/session-context-usage.tsx`
- `../happy-adjacent/research/opencode/packages/app/src/components/session/session-context-tab.tsx`
## What Happy should steal
- envelope + typed-parts transcript structure
- child-session subagent model with resumable IDs
- first-class todo and permission stores
- event stream for freshness plus fetch API for hydration
- built-in raw context/messages inspector in the UI
@@ -0,0 +1,818 @@
# OpenCode Runtime Tracing
Reviewed on 2026-03-21.
This is the protocol-level pass that the first writeup was missing.
The goal here was simple: run OpenCode from source, copy auth from the global
install into an isolated temp root, drive a real sample project, and only trust:
- exact request bodies sent to OpenCode
- exact JSON responses from OpenCode endpoints
- raw `/event` SSE logs from OpenCode
- OpenCode source code
For the Happy side of the comparison, this document only trusts Happy code.
## Setup That Was Actually Used
OpenCode source checkout:
- `../happy-adjacent/research/opencode`
- commit `2e0d5d230893dbddcefb35a02f53ff2e7a58e5d0`
Sample project:
- `/Users/kirilldubovitskiy/projects/happy/environments/lab-rat-todo-project`
Isolated runtime root:
- `/tmp/opencode-trace-dev.ptZAVJ`
Auth source and copy:
- source: `~/.local/share/opencode/auth.json`
- copied into:
`/tmp/opencode-trace-dev.ptZAVJ/share/opencode/auth.json`
- only provider key present in the copied auth file was `openai`
Server command:
```bash
XDG_DATA_HOME=/tmp/opencode-trace-dev.ptZAVJ/share \
XDG_CACHE_HOME=/tmp/opencode-trace-dev.ptZAVJ/cache \
XDG_CONFIG_HOME=/tmp/opencode-trace-dev.ptZAVJ/config \
XDG_STATE_HOME=/tmp/opencode-trace-dev.ptZAVJ/state \
OPENCODE_CONFIG_DIR=/tmp/opencode-trace-dev.ptZAVJ/profile \
OPENCODE_DB=/tmp/opencode-trace-dev.ptZAVJ/share/opencode/opencode.db \
bun run --cwd packages/opencode --conditions=browser src/index.ts \
serve --hostname 127.0.0.1 --port 4098 --print-logs --log-level DEBUG
```
Important source files behind that setup:
- `../happy-adjacent/research/opencode/packages/opencode/src/auth/index.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/server/server.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/server/routes/experimental.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/session/message-v2.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/session/prompt.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/tool/task.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/permission/index.ts`
## What Counts As “Real” Here
The useful protocol evidence is not one big hidden RPC envelope. It is spread
across four surfaces:
1. The request bodies we send to `POST /session/:id/prompt_async`
2. The persisted message rows from `GET /session/:id/message`
3. The live patch stream from `GET /event`
4. The control-plane responses such as `/path`, `/permission`,
`/session/:id/children`, `/experimental/worktree`, and
`/experimental/workspace`
That distinction matters for Happy. OpenCode does **not** have one single
append-only transcript stream that already contains everything the UI needs.
It has:
- stable message rows with typed parts
- live patch events against those rows
- first-class side-channel events for permissions and session state
- separate workspace/worktree routing outside the transcript
## Flow 0: Directory Routing, Worktree, Workspace, “Sandbox”
Before touching prompts, I verified how the server scopes requests.
The key routing input is the header:
```http
x-opencode-directory: /Users/kirilldubovitskiy/projects/happy/environments/lab-rat-todo-project
```
Real `GET /path` response:
```json
{
"home": "/Users/kirilldubovitskiy",
"state": "/tmp/opencode-trace-dev.ptZAVJ/state/opencode",
"config": "/tmp/opencode-trace-dev.ptZAVJ/config/opencode",
"worktree": "/Users/kirilldubovitskiy/projects/happy",
"directory": "/Users/kirilldubovitskiy/projects/happy/environments/lab-rat-todo-project"
}
```
Real empty listings for the current project:
```json
GET /experimental/worktree -> []
GET /experimental/workspace -> []
```
What that means:
- project scoping is a request-routing concern, not a transcript concern
- the current project lives under a `worktree` root and a narrower `directory`
- worktrees and workspaces are explicit control-plane resources
- none of this shows up as transcript parts like `type: "sandbox"` or
`type: "workspace"`
So when OpenCode product language says “sandbox”, the concrete implementation
here is mostly:
- directory scoping
- optional workspace routing
- optional git worktree management
It is **not** the same kind of OS/filesystem/network sandbox policy that Happy
already has code for in `packages/happy-cli/src/sandbox/config.ts`.
## Flow 1: Permission Ask Around `apply_patch`
This was the cleanest real trace because it exercised:
- user prompt creation
- assistant step lifecycle
- reasoning
- tool call
- permission request
- permission reply
- file edit side effects
- final assistant follow-up
### 1. Session creation
I created the session with an explicit edit permission rule that forces asks:
```json
POST /session
{
"title": "trace permission ask",
"permission": [
{
"permission": "edit",
"pattern": "*",
"action": "ask"
}
]
}
```
### 2. Prompt body sent
```json
POST /session/{sessionID}/prompt_async
{
"agent": "build",
"model": {
"providerID": "openai",
"modelID": "gpt-5.4-mini"
},
"parts": [
{
"type": "text",
"text": "Create a new file named TRACE_PERMISSION.md in the current directory with exactly one line: rat permission trace. Then reply with one short sentence."
}
]
}
```
### 3. User message persisted
```json
{
"info": {
"role": "user",
"id": "msg_d0f8263b50016s8bKlZ36Te52c",
"sessionID": "ses_2f07d9c71ffeikGiLoOKqF2Evb"
},
"parts": [
{
"type": "text",
"text": "Create a new file named TRACE_PERMISSION.md in the current directory with exactly one line: rat permission trace. Then reply with one short sentence."
}
]
}
```
### 4. Live permission event
Real SSE event:
```json
{
"type": "permission.asked",
"properties": {
"id": "per_d0f826ef60011FReJ16cM2d0MK",
"sessionID": "ses_2f07d9c71ffeikGiLoOKqF2Evb",
"permission": "edit",
"patterns": [
"environments/lab-rat-todo-project/TRACE_PERMISSION.md"
],
"always": ["*"],
"tool": {
"messageID": "msg_d0f8263b9001UcnDrNrnbyYeyT",
"callID": "call_uJj6gIQfIPpSoBV9oOWBT7cF"
},
"metadata": {
"filepath": "environments/lab-rat-todo-project/TRACE_PERMISSION.md",
"files": [
{
"relativePath": "environments/lab-rat-todo-project/TRACE_PERMISSION.md",
"type": "add",
"after": "rat permission trace\n",
"additions": 1,
"deletions": 0
}
]
}
}
}
```
This is important. OpenCode permission is not just “tool X wants approval”.
The request carries:
- the permission kind
- exact path patterns
- a stable request id
- linkage back to the tool call
- a ready-to-render diff payload
### 5. Assistant message around the tool call
Persisted assistant message after approval:
```json
{
"info": {
"role": "assistant",
"finish": "tool-calls",
"id": "msg_d0f8263b9001UcnDrNrnbyYeyT"
},
"parts": [
{ "type": "step-start", "snapshot": "dfd3f0873ec51c2ddbf0b6b79acc154e5ab15c5d" },
{
"type": "reasoning",
"text": "**Creating a file**\n\nI need to create a file...",
"metadata": {
"openai": {
"itemId": "rs_...",
"reasoningEncryptedContent": "gAAAAA..."
}
}
},
{
"type": "tool",
"callID": "call_uJj6gIQfIPpSoBV9oOWBT7cF",
"tool": "apply_patch",
"state": {
"status": "completed",
"input": {
"patchText": "*** Begin Patch\n*** Add File: TRACE_PERMISSION.md\n+rat permission trace\n*** End Patch"
},
"output": "Success. Updated the following files:\nA environments/lab-rat-todo-project/TRACE_PERMISSION.md"
}
},
{ "type": "step-finish", "reason": "tool-calls" }
]
}
```
Then OpenCode emitted a second assistant message with the final visible text:
```json
{
"info": {
"role": "assistant",
"finish": "stop"
},
"parts": [
{ "type": "step-start" },
{ "type": "text", "text": "Done." },
{ "type": "step-finish", "reason": "stop" }
]
}
```
### 6. The live event sequence actually seen
The raw SSE stream showed this order:
1. `session.created`
2. `message.updated` for the user message
3. `message.part.updated` for the user `text` part
4. `session.status` -> `busy`
5. `message.updated` for the assistant message shell
6. `message.part.updated` -> `step-start`
7. `message.part.updated` -> `reasoning`
8. many `message.part.delta` chunks streaming the reasoning text
9. `message.part.updated` -> tool part with `status: "pending"`
10. `permission.asked`
11. `permission.replied`
12. `file.edited`
13. `file.watcher.updated`
14. `message.part.updated` -> tool part `status: "running"`
15. `message.part.updated` -> tool part `status: "completed"`
16. `message.part.updated` -> `step-finish`
17. second assistant message with final `text`
18. `session.status` -> `idle`
The file really was created on disk:
```text
TRACE_PERMISSION.md: rat permission trace
```
## Flow 2: Media Input Failure Path
The failure case is useful because it shows what OpenCode stores before the
provider rejects the request.
### 1. Prompt body sent
```json
{
"agent": "build",
"model": {
"providerID": "openai",
"modelID": "gpt-5.4-mini"
},
"parts": [
{
"type": "text",
"text": "Describe the attached image in one short sentence. Do not use any tools."
},
{
"type": "file",
"mime": "image/png",
"filename": "tiny.png",
"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z0XQAAAAASUVORK5CYII="
}
]
}
```
### 2. User message persisted
```json
{
"info": { "role": "user" },
"parts": [
{
"type": "text",
"text": "Describe the attached image in one short sentence. Do not use any tools."
},
{
"type": "file",
"mime": "image/png",
"filename": "tiny.png",
"url": "data:image/png;base64,iVBORw0K..."
}
]
}
```
### 3. Assistant error persisted
```json
{
"info": {
"role": "assistant",
"error": {
"name": "APIError",
"data": {
"message": "The image data you provided does not represent a valid image. Please check your input and try again.",
"statusCode": 400,
"isRetryable": false,
"metadata": {
"url": "https://api.openai.com/v1/responses"
}
}
}
},
"parts": []
}
```
The live stream also emitted `session.error`.
This is a good example of OpenCode being honest: it stores the user-side file
part exactly as sent, and the failure becomes assistant/session error state
instead of getting normalized away.
## Flow 3: Media Input Success Path
The successful media path looked different because the input URL was a local
`file://...` URL, which OpenCode resolved itself.
### 1. Prompt body sent
```json
{
"agent": "build",
"model": {
"providerID": "openai",
"modelID": "gpt-5.4-mini"
},
"parts": [
{
"type": "text",
"text": "Describe the attached image in one short sentence. Do not use any tools."
},
{
"type": "file",
"mime": "image/png",
"filename": "logo.png",
"url": "file:///Users/kirilldubovitskiy/projects/happy/logo.png"
}
]
}
```
### 2. User message persisted after OpenCode normalized it
```json
{
"info": { "role": "user" },
"parts": [
{
"type": "text",
"text": "Describe the attached image in one short sentence. Do not use any tools."
},
{
"type": "text",
"synthetic": true,
"text": "Called the Read tool with the following input: {\"filePath\":\"/Users/kirilldubovitskiy/projects/happy/logo.png\"}"
},
{
"type": "file",
"mime": "image/png",
"filename": "logo.png",
"url": "data:image/png;base64,iVBORw0K..."
}
]
}
```
That is a real behavior from `session/prompt.ts`: OpenCode injects a synthetic
text part describing the read, then stores the media itself as a `file` part.
### 3. Assistant response persisted
```json
{
"info": {
"role": "assistant",
"finish": "stop"
},
"parts": [
{ "type": "step-start" },
{
"type": "reasoning",
"text": "",
"metadata": {
"openai": {
"itemId": "rs_...",
"reasoningEncryptedContent": "gAAAAA..."
}
}
},
{
"type": "text",
"text": "A cute cartoon otter is lounging in water while using a laptop."
},
{ "type": "step-finish", "reason": "stop" }
]
}
```
### 4. What the live stream proves
The `/event` log showed:
- `message.part.updated` for the synthetic read text
- `message.part.updated` for the `file` part
- reasoning creation
- streamed `message.part.delta` chunks for the assistant text
- `session.status` returning to `idle`
So the real media story is:
- user-facing prompt part: `type: "file"`
- internal transcript expansion: synthetic `text` plus concrete `file`
- assistant-side answer: ordinary text output
## Flow 4: Subtask / Child Session / Permission Constraining
This was the most important trace for side-by-side comparison with Happy.
### 1. Prompt body sent
```json
{
"agent": "build",
"model": {
"providerID": "openai",
"modelID": "gpt-5.4-mini"
},
"parts": [
{
"type": "text",
"text": "Find the main files in this tiny project and report back briefly."
},
{
"type": "agent",
"name": "explore"
}
]
}
```
### 2. User message persisted
OpenCode did **not** store just the raw `agent` part. It rewrote the user
message into:
```json
{
"info": { "role": "user" },
"parts": [
{
"type": "text",
"text": "Find the main files in this tiny project and report back briefly."
},
{
"type": "agent",
"name": "explore"
},
{
"type": "text",
"synthetic": true,
"text": " Use the above message and context to generate a prompt and call the task tool with subagent: explore"
}
]
}
```
Again, this is a real `session/prompt.ts` behavior, not a guess.
### 3. Parent assistant message and `task` tool part
```json
{
"info": {
"role": "assistant",
"finish": "tool-calls",
"id": "msg_d0f855de2001b0RbgA3JGA5lzk"
},
"parts": [
{ "type": "step-start" },
{
"type": "reasoning",
"text": "**Generating a task prompt**\n\nI need to call the task tool with the subagent explore..."
},
{
"type": "tool",
"callID": "call_OqUEr7ccnf3zEb2rgLYDp5uR",
"tool": "task",
"state": {
"status": "completed",
"input": {
"description": "Find main project files",
"prompt": "Inspect the repository and identify the main files in this tiny project. Focus on the key entry points, config files, and any top-level files that define how the project runs. Return a brief list of the most important files with one short note each about what they appear to do. Keep it concise and do not modify anything.",
"subagent_type": "explore"
},
"output": "task_id: ses_2f07a8dd6ffeRc23sIIgM4ZpMT (for resuming to continue this task if needed)\n\n<task_result>\nMain files:\n\n- `.../index.html` ...\n- `.../app.js` ...\n- `.../styles.css` ...\n- `.../README.md` ...\n\nNo build/config files are present; it looks like a simple frontend-only static app.\n</task_result>",
"metadata": {
"sessionId": "ses_2f07a8dd6ffeRc23sIIgM4ZpMT",
"model": {
"modelID": "gpt-5.4-mini",
"providerID": "openai"
}
}
}
},
{ "type": "step-finish", "reason": "tool-calls" }
]
}
```
Two key points:
- the parent transcript stores the `task` tool invocation and result
- resumability is by child session id, returned as `task_id`
### 4. Child session actually created
Real `GET /session/{parentID}/children` response:
```json
[
{
"id": "ses_2f07a8dd6ffeRc23sIIgM4ZpMT",
"parentID": "ses_2f07aa25affeqiZHSnBiN8pSyG",
"title": "Find main project files (@explore subagent)",
"directory": "/Users/kirilldubovitskiy/projects/happy/environments/lab-rat-todo-project",
"permission": [
{ "permission": "todowrite", "pattern": "*", "action": "deny" },
{ "permission": "todoread", "pattern": "*", "action": "deny" },
{ "permission": "task", "pattern": "*", "action": "deny" }
]
}
]
```
This is the cleanest proof that OpenCode subagents are child sessions with
their own identity and their own permission rules.
### 5. Live stream across parent and child
The raw `/event` stream showed:
1. parent user message created
2. parent assistant `step-start`
3. parent reasoning deltas
4. parent `tool` part for `task` becomes `pending`
5. `session.created` for the child session
6. parent `tool` part becomes `running`
7. child user message appears in the child session
8. child assistant message starts
9. child reasoning deltas stream
10. child session reaches `idle`
11. parent `tool` part becomes `completed`
So OpenCode is not faking subagents inside one flat message lane. It uses:
- parent session transcript
- child session transcript
- parent tool metadata linking to the child session id
## Side By Side With Happys Current Code
This section uses only Happy code, not Happy runtime traces.
| Topic | OpenCode, proven by logs/code | Happy, proven by code |
|---|---|---|
| Outer envelope | message rows already have top-level `info` plus ordered typed `parts` | `packages/happy-wire/src/messages.ts` still wraps the newer format as `role: "session"` with inner `content: sessionEnvelope` |
| Event discriminant | parts use top-level `type` like `text`, `reasoning`, `tool`, `file`, `agent`, `subtask`, `step-start` | `packages/happy-wire/src/sessionProtocol.ts` still nests event type under `ev.t` |
| Permissions | live `permission.asked` and `permission.replied` events carry tool linkage and diff metadata | `packages/happy-app/sources/sync/reducer/reducer.ts` still reconstructs permission state by merging transcript-ish messages with encrypted `agentState` |
| Subagents | real child sessions with `parentID`; `task_id` is resumable child session id | `packages/happy-wire/src/sessionProtocol.ts` only has optional `subagent` on envelopes, not child-session identity plus transcript-level linkage |
| Media | user `file` part plus synthetic helper `text`; successful local files become concrete `data:` URLs | `packages/happy-wire/src/sessionProtocol.ts` only has one `file` event shape; plan doc proposes direct `photo` / `video` / `file` variants |
| Sandbox / isolation | routing is by directory/workspace and optional worktrees; “sandbox” is mostly worktree/workspace language | `packages/happy-cli/src/sandbox/config.ts` already has concrete filesystem allow/deny rules plus network modes |
| Client complexity | OpenCode reducers merge live patches into already-typed message rows | `packages/happy-app/sources/sync/typesRaw.ts` and `packages/happy-app/sources/sync/reducer/reducer.ts` still carry legacy families plus complex reconstruction logic |
The main conclusion is blunt:
- OpenCode has the cleaner transcript shape
- Happy has the stronger real sandbox config
- Happys current reducer complexity is the strongest argument against keeping
multiple plaintext payload families alive
## What This Means For `provider-envelope-redesign.md`
Current planning context from `docs/plans/provider-envelope-redesign.md` still
looks right:
- the p6 envelope redesign work is in the dirty worktree, not committed branch
history
- that work already proved useful cleanup moves:
`type` at top level, no outer `role: "session"`, `parentId`/`agentId`,
transcript permissions, direct media variants
- the current proposal in that plan doc is still the plan of record
- OpenCode raw protocol shape remains the strongest outside reference to
evaluate before locking a new steady-state Happy schema
- Claudes older transcript-like format is still a plausible fallback if the
simplest stable model turns out to be closer to that history
OpenCode does **not** argue for copying ACP wrapper behavior. It argues for
copying the raw transcript shape:
- stable message rows
- typed parts
- explicit permission objects
- explicit child-session identity
- clear separation between transcript state and live patch transport
## The Hard Part For Happy: Encrypted Storage
This is where OpenCode and Happy diverge most.
OpenCode can happily keep canonical message rows and patch parts over time
because its storage layer sees plaintext session state.
Happy stores opaque encrypted blobs. That means copying OpenCode literally
forces a storage decision.
### Option A: Append-only canonical transcript events
Store already-normalized encrypted records that are durable on their own.
Example mental model:
```json
{ "kind": "agent-event", "type": "tool-start", ... }
{ "kind": "agent-event", "type": "permission-request", ... }
{ "kind": "agent-event", "type": "tool-end", ... }
```
Pros:
- keeps storage immutable
- refetch is simple
- matches Happys current transport assumptions
- avoids replaying raw deltas to rebuild a usable transcript
Cons:
- not a literal copy of OpenCodes patching model
- either start/end events stay separate forever, or the client must derive
“latest state” views for UI convenience
### Option B: Patch canonical encrypted message rows
Keep stable encrypted message ids, but rewrite the encrypted payload when parts
gain new state so refetch returns the newest canonical snapshot.
Example mental model:
```json
{
"messageId": "msg_123",
"parts": [
{ "type": "tool", "state": { "status": "pending" } }
]
}
```
Later rewritten as:
```json
{
"messageId": "msg_123",
"parts": [
{
"type": "tool",
"state": {
"status": "completed",
"input": { ... },
"output": "..."
}
}
]
}
```
Pros:
- closest to OpenCodes server-side model
- refetch gives the latest canonical state directly
- fewer client-side reconstruction problems
Cons:
- encrypted message rows become mutable
- sync/versioning gets more delicate
- you lose pure append-only history unless you also keep a shadow event log
### Option C: Append raw patch events and reconstruct on the client
Store the raw stream and rebuild message state client-side.
Example mental model:
```json
{ "type": "message.updated", ... }
{ "type": "message.part.updated", ... }
{ "type": "message.part.delta", ... }
{ "type": "permission.asked", ... }
```
Pros:
- closest to the live OpenCode stream
- fully immutable if every patch is appended
Cons:
- this is exactly the direction most likely to recreate Happys current reducer
pain
- refetch requires replay/materialization
- encrypted storage plus legacy format support makes this the highest-complexity
option
### Recommendation
If Happy borrows from OpenCode, it should probably steal the **shape** but not
the entire persistence strategy.
The strongest options are:
1. append-only **canonical** events
2. patchable **canonical** message snapshots
The weakest option is:
- raw patch-stream reconstruction as the primary durable format
That would preserve too much of the complexity we are trying to get rid of.
+32
View File
@@ -0,0 +1,32 @@
# OpenCode Sources
Reviewed on 2026-03-21.
- repo: `https://github.com/sst/opencode`
- checkout: `../happy-adjacent/research/opencode`
- commit: `2e0d5d230893dbddcefb35a02f53ff2e7a58e5d0`
## Primary files inspected
- `../happy-adjacent/research/opencode/packages/opencode/src/session/message-v2.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/session/index.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/session/prompt.ts`
- `../happy-adjacent/research/opencode/packages/sdk/js/src/v2/gen/types.gen.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/tool/task.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/tool/todo.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/permission/index.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/permission/evaluate.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/server/server.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/server/routes/experimental.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/auth/index.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/control-plane/workspace-server/routes.ts`
- `../happy-adjacent/research/opencode/packages/opencode/src/control-plane/sse.ts`
- `../happy-adjacent/research/opencode/packages/app/src/context/global-sync.tsx`
- `../happy-adjacent/research/opencode/packages/app/src/context/global-sync/event-reducer.ts`
- `../happy-adjacent/research/opencode/packages/app/src/components/session-context-usage.tsx`
- `../happy-adjacent/research/opencode/packages/app/src/components/session/session-context-tab.tsx`
## Notes
- The context/debug surface is not a side detail; it is one of the strongest product ideas in the repo.
- The server split deserves deeper follow-up work after this first pass.
+405
View File
@@ -0,0 +1,405 @@
#!/usr/bin/env bash
#
# trace-opencode.sh — reusable harness for tracing OpenCode's protocol
#
# Sets up an isolated OpenCode server, runs scripted flows against it,
# and captures raw request/response/SSE logs.
#
# Usage:
# ./scripts/trace-opencode.sh [flow ...]
#
# flows: permission, media, subtask, question, todo, all (default: all)
#
# Prerequisites:
# - OpenCode source checkout at ../happy-adjacent/research/opencode
# - bun installed
# - Auth file at ~/.local/share/opencode/auth.json (with at least one provider key)
#
# Output:
# docs/competition/opencode/traces/<timestamp>/
# setup.json — runtime config used
# flow-<name>.json — per-flow request/response/events log
# sse-raw.log — raw SSE stream dump
#
set -euo pipefail
# ── Config ──────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OPENCODE_SRC="${OPENCODE_SRC:-$REPO_ROOT/../happy-adjacent/research/opencode}"
AUTH_SOURCE="${AUTH_SOURCE:-$HOME/.local/share/opencode/auth.json}"
LAB_RAT_DIR="$REPO_ROOT/environments/lab-rat-todo-project"
PORT="${OPENCODE_TRACE_PORT:-0}"
PROVIDER="${OPENCODE_TRACE_PROVIDER:-openai}"
MODEL="${OPENCODE_TRACE_MODEL:-gpt-4.1-mini}"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
TRACE_DIR="$REPO_ROOT/docs/competition/opencode/traces/$TIMESTAMP"
TMPROOT=""
SERVER_PID=""
SSE_PID=""
# ── Helpers ─────────────────────────────────────────────────
cleanup() {
echo ""
echo "=== cleanup ==="
[ -n "$SSE_PID" ] && kill "$SSE_PID" 2>/dev/null || true
[ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null || true
[ -n "$TMPROOT" ] && rm -rf "$TMPROOT"
echo "traces saved to: $TRACE_DIR"
}
trap cleanup EXIT
die() { echo "ERROR: $*" >&2; exit 1; }
check_prereqs() {
[ -d "$OPENCODE_SRC/packages/opencode/src" ] || die "OpenCode source not found at $OPENCODE_SRC"
command -v bun >/dev/null || die "bun not installed"
[ -f "$AUTH_SOURCE" ] || die "auth file not found at $AUTH_SOURCE"
[ -d "$LAB_RAT_DIR" ] || die "lab-rat project not found at $LAB_RAT_DIR"
command -v jq >/dev/null || die "jq not installed"
command -v curl >/dev/null || die "curl not installed"
}
# POST/GET with logging
api() {
local method="$1" path="$2" flow="$3"
shift 3
local url="http://127.0.0.1:$PORT$path"
local log_file="$TRACE_DIR/flow-${flow}.jsonl"
local response
if [ "$method" = "GET" ]; then
response=$(curl -sS -w '\n{"_http_code":%{http_code}}' \
-H "x-opencode-directory: $LAB_RAT_DIR" \
"$url" "$@" 2>&1)
else
response=$(curl -sS -w '\n{"_http_code":%{http_code}}' \
-X "$method" \
-H "Content-Type: application/json" \
-H "x-opencode-directory: $LAB_RAT_DIR" \
"$url" "$@" 2>&1)
fi
# Log the raw exchange
local entry
entry=$(jq -cn \
--arg method "$method" \
--arg path "$path" \
--arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg response "$response" \
'{method: $method, path: $path, time: $time, response: $response}')
echo "$entry" >> "$log_file"
# Return just the response body (strip the http_code line)
echo "$response" | head -n -1
}
wait_for_server() {
echo "waiting for server on port $PORT..."
local tries=0
while ! curl -s "http://127.0.0.1:$PORT/path" \
-H "x-opencode-directory: $LAB_RAT_DIR" >/dev/null 2>&1; do
tries=$((tries + 1))
[ "$tries" -gt 30 ] && die "server did not start within 30s"
sleep 1
done
echo "server ready"
}
wait_for_idle() {
local session_id="$1"
local timeout="${2:-60}"
local start=$SECONDS
echo " waiting for session $session_id to go idle..."
while true; do
local elapsed=$(( SECONDS - start ))
[ "$elapsed" -gt "$timeout" ] && die "session did not go idle within ${timeout}s"
# Check the SSE log for idle status
if grep -q "\"session.status\"" "$TRACE_DIR/sse-raw.log" 2>/dev/null; then
local last_status
last_status=$(grep "\"session.status\"" "$TRACE_DIR/sse-raw.log" | tail -1 | \
sed 's/^data: //' | jq -r '.properties.status.type // empty' 2>/dev/null || true)
if [ "$last_status" = "idle" ]; then
echo " session idle"
return 0
fi
fi
sleep 0.5
done
}
reply_permission() {
local flow="$1" reply="${2:-once}"
echo " checking for pending permissions..."
sleep 1 # give it a moment
local perms
perms=$(api GET "/permission" "$flow")
local perm_id
perm_id=$(echo "$perms" | jq -r '.[0].id // empty' 2>/dev/null || true)
if [ -n "$perm_id" ]; then
echo " replying '$reply' to permission $perm_id"
api POST "/permission/$perm_id/reply" "$flow" \
-d "{\"reply\":\"$reply\"}"
else
echo " no pending permissions"
fi
}
reply_question() {
local flow="$1"
echo " checking for pending questions..."
sleep 1
local questions
questions=$(api GET "/question" "$flow" 2>/dev/null || echo "[]")
local q_id
q_id=$(echo "$questions" | jq -r '.[0].id // empty' 2>/dev/null || true)
if [ -n "$q_id" ]; then
# auto-answer with the first option for each question
local answers
answers=$(echo "$questions" | jq -c '[.[0].questions[] | [.options[0].label]]')
echo " replying to question $q_id with: $answers"
api POST "/question/$q_id/reply" "$flow" \
-d "{\"answers\":$answers}"
else
echo " no pending questions"
fi
}
# ── Setup ───────────────────────────────────────────────────
check_prereqs
# Create isolated temp root
TMPROOT=$(mktemp -d /tmp/opencode-trace.XXXXXX)
mkdir -p "$TMPROOT"/{share/opencode,cache,config,state,profile}
cp "$AUTH_SOURCE" "$TMPROOT/share/opencode/auth.json"
# Create output dir
mkdir -p "$TRACE_DIR"
echo "=== OpenCode Protocol Tracing ==="
echo " source: $OPENCODE_SRC"
echo " lab-rat: $LAB_RAT_DIR"
echo " tmproot: $TMPROOT"
echo " output: $TRACE_DIR"
echo ""
# Find a free port if PORT=0
if [ "$PORT" = "0" ]; then
PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()')
fi
# Save setup info
jq -n \
--arg opencode_src "$OPENCODE_SRC" \
--arg lab_rat "$LAB_RAT_DIR" \
--arg tmproot "$TMPROOT" \
--arg port "$PORT" \
--arg provider "$PROVIDER" \
--arg model "$MODEL" \
--arg timestamp "$TIMESTAMP" \
'{opencode_src: $opencode_src, lab_rat: $lab_rat, tmproot: $tmproot,
port: ($port | tonumber), provider: $provider, model: $model,
timestamp: $timestamp}' \
> "$TRACE_DIR/setup.json"
# ── Start server ────────────────────────────────────────────
echo "=== starting opencode server on port $PORT ==="
XDG_DATA_HOME="$TMPROOT/share" \
XDG_CACHE_HOME="$TMPROOT/cache" \
XDG_CONFIG_HOME="$TMPROOT/config" \
XDG_STATE_HOME="$TMPROOT/state" \
OPENCODE_CONFIG_DIR="$TMPROOT/profile" \
OPENCODE_DB="$TMPROOT/share/opencode/opencode.db" \
bun run --cwd "$OPENCODE_SRC/packages/opencode" --conditions=browser src/index.ts \
serve --hostname 127.0.0.1 --port "$PORT" --print-logs --log-level DEBUG \
> "$TRACE_DIR/server.log" 2>&1 &
SERVER_PID=$!
wait_for_server
# Start SSE listener
echo "=== starting SSE listener ==="
curl -sS -N "http://127.0.0.1:$PORT/event" \
-H "x-opencode-directory: $LAB_RAT_DIR" \
> "$TRACE_DIR/sse-raw.log" 2>&1 &
SSE_PID=$!
sleep 0.5
# ── Parse flow arguments ────────────────────────────────────
FLOWS=("$@")
[ ${#FLOWS[@]} -eq 0 ] && FLOWS=(all)
run_flow() {
local name="$1"
case "$name" in
all)
run_flow permission
run_flow media
run_flow subtask
run_flow todo
;;
permission)
echo ""
echo "=== Flow: permission ==="
echo " creating session with edit ask rule..."
local session
session=$(api POST "/session" permission \
-d "{\"title\":\"trace-permission\",\"permission\":[{\"permission\":\"edit\",\"pattern\":\"*\",\"action\":\"ask\"}]}")
local sid
sid=$(echo "$session" | jq -r '.id')
echo " session: $sid"
echo " sending prompt..."
api POST "/session/$sid/prompt_async" permission \
-d "{\"agent\":\"build\",\"model\":{\"providerID\":\"$PROVIDER\",\"modelID\":\"$MODEL\"},\"parts\":[{\"type\":\"text\",\"text\":\"Create a file named TRACE_PERM.md with exactly one line: permission trace. Then reply with one short sentence.\"}]}"
sleep 2
reply_permission permission once
wait_for_idle "$sid" 60
echo " fetching messages..."
api GET "/session/$sid/message" permission
echo " fetching children..."
api GET "/session/$sid/children" permission
echo " flow: permission done"
;;
media)
echo ""
echo "=== Flow: media ==="
local session
session=$(api POST "/session" media \
-d '{"title":"trace-media"}')
local sid
sid=$(echo "$session" | jq -r '.id')
echo " session: $sid"
# Test with a tiny inline PNG (1x1 red pixel)
local tiny_png="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
echo " sending prompt with inline image..."
api POST "/session/$sid/prompt_async" media \
-d "{\"agent\":\"build\",\"model\":{\"providerID\":\"$PROVIDER\",\"modelID\":\"$MODEL\"},\"parts\":[{\"type\":\"text\",\"text\":\"Describe the attached image in one short sentence. Do not use any tools.\"},{\"type\":\"file\",\"mime\":\"image/png\",\"filename\":\"tiny.png\",\"url\":\"data:image/png;base64,$tiny_png\"}]}"
wait_for_idle "$sid" 60
echo " fetching messages..."
api GET "/session/$sid/message" media
echo " flow: media done"
;;
subtask)
echo ""
echo "=== Flow: subtask ==="
local session
session=$(api POST "/session" subtask \
-d '{"title":"trace-subtask"}')
local sid
sid=$(echo "$session" | jq -r '.id')
echo " session: $sid"
echo " sending prompt with @explore agent..."
api POST "/session/$sid/prompt_async" subtask \
-d "{\"agent\":\"build\",\"model\":{\"providerID\":\"$PROVIDER\",\"modelID\":\"$MODEL\"},\"parts\":[{\"type\":\"text\",\"text\":\"Find the main files in this tiny project and report back briefly.\"},{\"type\":\"agent\",\"name\":\"explore\"}]}"
wait_for_idle "$sid" 90
echo " fetching messages..."
api GET "/session/$sid/message" subtask
echo " fetching children..."
api GET "/session/$sid/children" subtask
echo " flow: subtask done"
;;
todo)
echo ""
echo "=== Flow: todo ==="
local session
session=$(api POST "/session" todo \
-d '{"title":"trace-todo"}')
local sid
sid=$(echo "$session" | jq -r '.id')
echo " session: $sid"
echo " sending prompt asking to create todos..."
api POST "/session/$sid/prompt_async" todo \
-d "{\"agent\":\"build\",\"model\":{\"providerID\":\"$PROVIDER\",\"modelID\":\"$MODEL\"},\"parts\":[{\"type\":\"text\",\"text\":\"Look at this project and create a todo list with 3 items about improvements. Use the todowrite tool.\"}]}"
# todowrite might need permission
sleep 3
reply_permission todo once
wait_for_idle "$sid" 60
echo " fetching messages..."
api GET "/session/$sid/message" todo
echo " fetching todos..."
api GET "/session/$sid/todo" todo
echo " flow: todo done"
;;
question)
echo ""
echo "=== Flow: question ==="
local session
session=$(api POST "/session" question \
-d '{"title":"trace-question"}')
local sid
sid=$(echo "$session" | jq -r '.id')
echo " session: $sid"
echo " sending prompt that should trigger question tool..."
api POST "/session/$sid/prompt_async" question \
-d "{\"agent\":\"build\",\"model\":{\"providerID\":\"$PROVIDER\",\"modelID\":\"$MODEL\"},\"parts\":[{\"type\":\"text\",\"text\":\"I want to set up a database for this project. Ask me which database I prefer before proceeding. Use the question tool.\"}]}"
sleep 3
reply_question question
# might also need permission for the question tool
reply_permission question once
wait_for_idle "$sid" 60
echo " fetching messages..."
api GET "/session/$sid/message" question
echo " flow: question done"
;;
*)
die "unknown flow: $name (available: permission, media, subtask, todo, question, all)"
;;
esac
}
# ── Run flows ───────────────────────────────────────────────
for flow in "${FLOWS[@]}"; do
run_flow "$flow"
done
echo ""
echo "=== all flows complete ==="
echo ""
echo "Output:"
echo " $TRACE_DIR/setup.json — runtime config"
echo " $TRACE_DIR/flow-*.jsonl — per-flow request/response logs"
echo " $TRACE_DIR/sse-raw.log — raw SSE event stream"
echo " $TRACE_DIR/server.log — server stdout/stderr"
echo ""
echo "Quick inspection:"
echo " jq -s '.' $TRACE_DIR/flow-permission.jsonl | less"
echo " grep 'data:' $TRACE_DIR/sse-raw.log | jq -r '.type' | sort | uniq -c | sort -rn"
+155
View File
@@ -0,0 +1,155 @@
# Superset
Deep research completed 2026-04-08 from `github.com/superset-sh/superset`.
**Repo:** `github.com/superset-sh/superset` | **Stars:** 9,092 | **License:** Elastic License 2.0 (ELv2)
## Why it matters
Superset takes a fundamentally different approach from other coding agents:
it focuses on **orchestration** rather than terminal emulation or streaming
event plumbing. It doesn't try to understand agent output — it launches
agents in real PTY terminals, observes their lifecycle via hooks, and
coordinates via git worktrees.
- 3-person team shipping daily for 5+ months (2,100+ commits, 80+ releases)
- excellent package boundaries — host-service is deployment-agnostic,
panes engine is framework-agnostic, sync is layered cleanly
- CLI can control the desktop app remotely via cloud DB command queue
- agent-agnostic: launches Claude, Codex, Gemini, etc. as opaque processes
## Current take
- Superset is the strongest reference for **orchestration-layer design**
how to coordinate multiple agents without owning their protocols.
- Their host-service extraction pattern (injectable providers, no Electron
awareness) is worth studying for Happy's own server/CLI split.
- The Electric SQL cloud-to-local sync is a real production pattern worth
understanding for Happy's sync story.
- Ship velocity is remarkable — they're actively doing a v2 refactor while
shipping features daily.
## Key architectural findings
### 1. Monorepo structure (Turborepo + Bun)
**Apps (7+):**
- `apps/desktop` — Electron desktop app (primary product), React 19, xterm.js
- `apps/api` — Next.js cloud API (Neon Postgres, Better Auth, tRPC)
- `apps/web` — Next.js web dashboard
- `apps/electric-proxy` — Cloudflare Worker proxying Electric SQL shape streams
- `apps/mobile` — Expo React Native mobile app
- `apps/admin`, `apps/docs`, `apps/marketing`
**Packages (15+):**
- `@superset/host-service`**core backend.** Hono HTTP + WebSocket. Manages
workspaces, terminals (node-pty), filesystem, git, AI chat, PRs. Own SQLite
DB (Drizzle + better-sqlite3). Zero Electron awareness — accepts injected
providers via `createApp()` factory.
- `@superset/workspace-client` — React client library. tRPC + React Query
clients pointing at a host-service instance.
- `@superset/shared` — Agent definitions, command building, task templates.
Zero framework dependencies.
- `@superset/cli` — Bun-compiled CLI. File-based command routing via
`@superset/cli-framework`. Commands: auth, devices, host, tasks, workspaces.
- `@superset/local-db` — Desktop-local SQLite (Drizzle). Projects, worktrees,
workspaces, settings, plus **synced tables** mirroring cloud Postgres via
Electric SQL.
- `@superset/db` — Cloud Postgres schema (Drizzle). Tasks, users, orgs,
agent commands, device presence.
- `@superset/panes`**Standalone binary-tree pane layout engine** with
Zustand vanilla store. Framework-agnostic core + React bindings. Tabs,
splits, drag-and-drop, resize.
- `@superset/workspace-fs` — Filesystem ops, fuzzy search (VS Code scorer
port), watching (@parcel/watcher), resource URIs.
- `@superset/mcp` — MCP server for remote device control: create/delete
workspaces, start agent sessions, switch workspaces, list devices.
- `@superset/chat` — AI chat runtime (client/server/shared).
### 2. Sync — three distinct layers
**Layer 1: Local SQLite** — per-device desktop state (projects, worktrees,
workspaces, settings). Schema at `packages/local-db/src/schema/schema.ts`.
**Layer 2: Electric SQL** — cloud-to-local real-time sync. The electric-proxy
Worker authenticates and proxies shape streams. Desktop uses
`@electric-sql/client` + `@tanstack/db` to subscribe and write into local
SQLite. Gives offline-capable access to org data, tasks, users.
**Layer 3: WebSocket EventBus** — real-time host-service events. Two event
types: `git:changed` (auto-broadcast on git state changes, 300ms debounce)
and `fs:events` (on-demand per-client filesystem subscriptions). Client-side
ref-counting, auto-reconnect with exponential backoff (1s30s).
**Layer 4: tRPC** — request-response over HTTP. Host-service exposes
`/trpc/*` routes for health, chat, filesystem, git, github, PRs, workspaces.
### 3. CLI controlling the UI — cloud-mediated command queue
The `agentCommands` table in cloud Postgres acts as a **command queue**:
1. CLI/MCP tool inserts a row: `status: "pending"`, `tool`, `params`,
`targetDeviceId`, `timeoutAt`
2. MCP server **polls** the row every 500ms waiting for completion
3. Desktop picks up pending commands (via Electric SQL sync), executes
locally, updates status to `completed`/`failed` with result
4. MCP server sees completion and returns result
No direct WebSocket from CLI to desktop — the cloud DB is the rendezvous
point. This is elegant for remote device control.
### 4. Host-service durability
Host-service **survives app restarts**. On spawn, writes a manifest file
(`~/.superset/host/<orgId>/manifest.json`) with `{pid, endpoint, authToken,
startedAt, protocolVersion}`. On next launch, `HostServiceManager` scans
manifests, health-checks PIDs, and adopts running instances. On normal quit,
detaches without killing services.
### 5. Orchestration model — agent-agnostic
The key insight: Superset does NOT parse or understand agent output streams.
- **Workspace isolation via git worktrees** — each task gets its own worktree
- **Agent-agnostic launch** — agents are CLI command strings launched in real
PTY terminals: `claude --dangerously-skip-permissions`, `codex --bypass...`,
`gemini --yolo`, etc.
- **Lifecycle observation, not control** — uses notify hooks and git watchers
to know when agents start/stop/need attention, but never injects into
stdin/stdout
- **Task → Agent mapping** — `buildAgentCommand()` renders task metadata into
a prompt template, writes to `.superset/task-<slug>.md`, passes via
`--resume` or stdin
- **Pane layout as orchestration surface** — binary-tree layout (like tmux)
with Zustand. Multiple agents in separate panes/tabs.
### 6. Agent lifecycle hooks
For Claude: merges hook definitions into `~/.claude/settings.json` that call
a `notify.sh` script → hits `GET http://localhost:<port>/hook/complete`
Express server receives, validates, emits via `notificationsEmitter`. This
is how the desktop knows when agents need attention.
## Ship velocity
- **2,176 commits** on main in ~5.5 months (since Oct 21, 2025)
- **3 core contributors** doing 95%+ of work: Kitenite (1,300), saddlepaddle
(460), AviPeltz (243)
- **15 releases in 17 days** (Mar 17 Apr 3, v1.2.0 → v1.4.7)
- **67 releases** from v0.0.12 to v0.0.67 (Dec 9 Feb 4)
- **~57 commits/day** with substantive features
- Currently doing a v2 architectural refactor while shipping daily
## Happy takeaways
- The host-service extraction pattern (injectable providers, zero Electron
awareness, manifest-based durability) is directly relevant to how Happy
structures its CLI/server split.
- Electric SQL for cloud-to-local sync is a production-proven pattern worth
evaluating against Happy's current sync approach.
- The cloud DB command queue for CLI→desktop control is clever — no direct
connection needed, works across networks.
- The "don't parse agent output, just observe lifecycle" philosophy is the
opposite of what OpenCode/Claude do — worth understanding the tradeoffs.
- Pane layout as a standalone package with Zustand is a good reference for
any layout engine work in Happy.
+242
View File
@@ -0,0 +1,242 @@
# Electric SQL — Sync Engine Evaluation
Research completed 2026-04-08.
## What it is
Electric SQL is a **read-path sync engine** — a standalone Elixir service
that sits between Postgres and client applications. It tails the Postgres
WAL (Write-Ahead Log) and streams subsets of data ("Shapes") to clients
over HTTP.
It is NOT a database, NOT a Postgres extension, NOT a write-path solution.
```
Postgres ──[logical replication]──> Electric (Elixir) ──[HTTP]──> CDN ──> Clients
```
## Open source
- **License**: Apache-2.0
- **Repo**: github.com/electric-sql/electric
- **Stars**: ~10,046
- **NPM downloads**: ~1.68M/month (`@electric-sql/client`)
- **Maintained by**: Electric DB Inc.
- **Status**: GA (1.0 released March 2025, currently 1.1+)
- **Commercial offering**: Electric Cloud (hosted, pay for writes + retention,
reads/fan-out free)
## Core API
### Server setup (Docker)
```yaml
electric:
image: electricsql/electric:latest
environment:
DATABASE_URL: postgresql://user:pass@host:5432/db
ELECTRIC_SECRET: your-secret
ports:
- "3000:3000"
```
Requires: Postgres 14+, `wal_level=logical`, user with `REPLICATION` role.
Electric creates in your database:
- Publication: `electric_publication_default`
- Replication slot: `electric_slot_default`
- Sets `REPLICA IDENTITY FULL` on synced tables
- No extensions required
### Client — React hooks
```tsx
import { useShape } from '@electric-sql/react'
function TaskList() {
const { isLoading, data } = useShape<Task>({
url: `http://localhost:3000/v1/shape`,
params: {
table: 'tasks',
where: `org_id = '123'`,
columns: `id,title,status`,
},
})
if (isLoading) return <div>Loading...</div>
return <ul>{data.map(t => <li key={t.id}>{t.title}</li>)}</ul>
}
```
### Client — vanilla TypeScript
```ts
import { ShapeStream, Shape } from '@electric-sql/client'
const stream = new ShapeStream({
url: `http://localhost:3000/v1/shape`,
params: {
table: 'tasks',
where: `org_id = '123'`,
},
})
const shape = new Shape(stream)
const rows = await shape.rows // wait for initial sync
shape.subscribe(({ rows }) => {
console.log('Updated:', rows)
})
```
### Raw HTTP API
```sh
# Initial sync — returns all matching rows
curl 'http://localhost:3000/v1/shape?table=tasks&offset=-1'
# Live updates — long-polls until new data arrives
curl 'http://localhost:3000/v1/shape?table=tasks&live=true&handle=abc&offset=0_5'
# SSE mode
curl 'http://localhost:3000/v1/shape?table=tasks&live=true&live_sse=true&handle=abc&offset=0_5'
# Changes only (skip initial snapshot)
curl 'http://localhost:3000/v1/shape?table=tasks&offset=-1&log=changes_only'
```
Response format:
```json
[
{"offset":"0_0","value":{"id":"1","title":"Fix bug"},"key":"\"public\".\"tasks\"/\"1\"","headers":{"operation":"insert"}},
{"headers":{"control":"up-to-date"}}
]
```
### Writes — BYO (by design)
Electric does NOT handle writes. You write through your own API:
```
Client → tRPC/REST mutation → your backend → Postgres INSERT
→ WAL → Electric → shape stream → all subscribers get the update
```
With TanStack DB, optimistic updates work via txid confirmation:
```ts
const collection = createCollection(
electricCollectionOptions<Task>({
shapeOptions: { url, params: { table: 'tasks' } },
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
const result = await apiClient.task.create.mutate(item)
return { txid: result.txid } // confirms optimistic write
},
}),
)
```
## Shapes — capabilities and limitations
A Shape is a declarative subset of one Postgres table.
**Can do:**
- Single table with WHERE filter, column selection
- SQL operators, boolean logic, LIKE, IN, comparisons
- Parameterized queries (`$1`, `$2`) for injection safety
- Subqueries (experimental): `id IN (SELECT user_id FROM memberships WHERE org_id = $1)`
- Progressive loading with ORDER BY, LIMIT, OFFSET
- Partitioned tables
**Cannot do:**
- No JOINs (single-table only — subscribe to multiple shapes, join client-side)
- No aggregations (COUNT, SUM, AVG)
- No non-deterministic functions (now(), random()) in WHERE
- Shapes are immutable — switch context = new subscription
## How sync works under the hood
1. Electric connects to Postgres via **logical replication** (standard PG
feature used for standby replicas)
2. Creates a publication and replication slot
3. `ShapeLogCollector` GenServer consumes WAL stream, evaluates each
INSERT/UPDATE/DELETE against registered shapes' WHERE clauses
4. Matching changes are distributed to shape-specific logs
5. Clients consume via HTTP long-polling or SSE
6. `up-to-date` control message = caught up to current state
7. `must-refetch` = server needs client to re-sync from scratch
**Consistency**: changes delivered in WAL (LSN) order within a shape.
Eventually consistent. No gaps.
**No conflict resolution**: Electric is read-only. Conflicts only exist
in your write path — your problem to solve.
## Performance
| Metric | Value |
|--------|-------|
| Live update latency (optimized WHERE) | **6ms** (3ms PG + 3ms Electric) |
| Live update latency (non-optimized WHERE) | ~100ms at 10K shapes |
| Write throughput | **4,0006,000 rows/sec** |
| Initial sync | CDN-cacheable at edge |
| Concurrent clients tested | 2K clients × 500-row shapes |
| Single client max | 1M rows with linear sync time |
| Production scale | 100K1M concurrent users |
**CDN caching is the scaling secret**: initial sync responses are HTTP-cacheable.
Live-mode long-polling requests are collapsed by CDN (N clients waiting =
1 upstream request). This means Electric + CDN scales to millions with
minimal Postgres load.
## Frontend integration
| Framework | Package | Status |
|-----------|---------|--------|
| React | `@electric-sql/react` | First-class |
| React Native / Expo | `@electric-sql/react` | Works (same client) |
| TanStack DB | `@tanstack/electric-db-collection` | Deep integration |
| Next.js | Via `@electric-sql/react` | Integration guide |
| Phoenix/LiveView | `phoenix_sync` | First-class (Elixir) |
| Yjs | `y-electric` | Integration package |
| Any language | HTTP + JSON | Roll your own |
Note: PGlite does NOT yet work in React Native.
## Recommended stack (what Superset uses)
```
Reads: Postgres → Electric → Cloudflare Worker (auth proxy) → TanStack DB
Writes: Client → tRPC mutation → API server → Postgres → Electric confirms
```
This is the officially recommended "TanStack" stack in Electric's docs.
Superset validates it works in production at scale.
## Comparison to alternatives
| | Electric | Custom WS | Supabase Realtime | PowerSync | CRDTs |
|---|---|---|---|---|---|
| Protocol | HTTP (CDN-cacheable) | WS (stateful) | WS | Custom | P2P |
| Initial sync | Built-in + cached | Build yourself | Separate query | Built-in | Built-in |
| Writes | BYO | Full control | Built-in | Built-in (sync rules) | Automatic |
| Scaling | CDN fan-out | Connection-bound | Connection-bound | Medium | P2P |
| Conflicts | BYO | BYO | N/A | Built-in | Automatic (math) |
| Best for | Structured data sync | Full control | Notifications | Mobile offline | Collaborative editing |
| Complexity | Low | High | Medium | Medium | High |
## Relevance for Happy
**Strong fit:**
- Read-heavy pattern (streaming agent state to UI) maps perfectly
- CDN caching scales without Postgres load
- 6ms latency for live agent activity updates
- HTTP works everywhere (web, mobile, CLI)
- Postgres stays source of truth with full SQL power
- Expo/React Native support
**Concerns:**
- No joins — need multiple shapes + client-side joining
- Must build own write path (fine — agent commands go through backend anyway)
- One more service to operate (or use Electric Cloud)
- Shapes are immutable (session switch = new subscription)
+52
View File
@@ -0,0 +1,52 @@
# Superset Sources
Reviewed on 2026-04-08.
- repo: `https://github.com/superset-sh/superset`
- site: `https://superset.sh`
- stars: 9,092 (as of 2026-04-08)
- license: Elastic License 2.0 (ELv2)
- current version: desktop-v1.4.7
## Key files inspected
- `apps/desktop/src/main/lib/host-service-manager.ts` — host-service spawn, manifest, adopt
- `apps/desktop/src/main/host-service/index.ts` — host-service Electron integration
- `apps/electric-proxy/src/electric.ts` — Electric SQL proxy worker
- `packages/host-service/src/events/event-bus.ts` — WebSocket event bus
- `packages/host-service/src/events/git-watcher.ts` — git change detection
- `packages/host-service/src/trpc/router/workspace/workspace.ts` — workspace/worktree management
- `packages/workspace-client/src/lib/eventBus.ts` — client-side event bus
- `packages/local-db/src/schema/schema.ts` — local SQLite schema with synced tables
- `packages/db/src/schema/` — cloud Postgres schema
- `packages/shared/src/agent-command.ts` — agent launch command builder
- `packages/shared/src/builtin-terminal-agents.ts` — agent type definitions
- `packages/panes/src/store.ts` — binary-tree pane layout engine
- `packages/mcp/src/tools/` — MCP tools for remote control
- `packages/cli/src/` — CLI command structure
- `apps/desktop/src/main/lib/agent-setup/` — agent hook injection
- `apps/desktop/src/main/lib/host-service-manifest.ts` — manifest format and I/O
- `apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts` — Electric SQL consumer, 22 shape subscriptions
- `apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/useCommandWatcher.ts` — command queue executor
- `apps/electric-proxy/src/index.ts` — Cloudflare Worker auth + proxy
- `packages/host-service/src/db/schema.ts` — host-service SQLite schema
- `packages/db/src/schema/auth.ts` — auth schema
- `packages/db/src/schema/github.ts` — GitHub schema
- `packages/trpc/src/router/agent/agent.ts` — agent command tRPC router
## Research files in this directory
- `README.md` — architecture overview, key findings, Happy takeaways
- `sources.md` — this file
- `sync-architecture.md` — full state ownership map, all data flow paths
- `terminal-sync.md` — terminal state sync (V1 daemon + V2 WebSocket architectures)
- `electric-sql.md` — Electric SQL evaluation (API, performance, alternatives)
## Notes
- Host-service architecture doc at `HOST_SERVICE_ARCHITECTURE.md` explicitly
states "deployable standalone with zero Electron awareness"
- The `agentCommands` cloud DB table is the CLI→desktop control mechanism
- Electric SQL is used via `@electric-sql/client` v1.5.13 + `@tanstack/db` v0.5.33
- Panes package uses Zustand vanilla store — framework-agnostic core
- camelCase columns = local-only, snake_case columns = synced from cloud
@@ -0,0 +1,296 @@
# Superset Sync Architecture
Deep dive completed 2026-04-08 from `github.com/superset-sh/superset`.
## High-level overview
```
┌─────────────────────────────────────────────────────────────────┐
│ CLOUD POSTGRES │
│ Source of truth for: users, orgs, tasks, agent commands, │
│ integrations, subscriptions, GitHub data, device presence │
│ │
│ Write path: tRPC mutations from any client │
│ Read path: Electric SQL WAL tailing → shape streams │
└───────────────┬─────────────────────────────┬───────────────────┘
│ Electric SQL │ tRPC
│ (real-time sync) │ (mutations)
▼ ▲
┌─────────────────────────────────────────────────────────────────┐
│ ELECTRIC PROXY (Cloudflare Worker) │
│ - JWT auth against JWKS endpoint │
│ - Injects WHERE org_id = ? per table (row-level security) │
│ - Strips sensitive columns (tokens, secrets) │
│ - Forwards Electric protocol params (live, offset, cursor) │
└───────────────┬─────────────────────────────────────────────────┘
│ HTTP shape streams
┌─────────────────────────────────────────────────────────────────┐
│ DESKTOP APP (Electron renderer) │
│ │
│ TanStack Electric Collections (22 shapes per org): │
│ tasks, taskStatuses, projects, members, users, │
│ agentCommands, integrations, subscriptions, ... │
│ │
│ Write: optimistic local update → tRPC → Postgres → │
│ Electric confirms via txid │
│ │
│ localStorage Collections (3, no sync): │
│ sidebarProjects, workspaceLocalState, sidebarSections │
└───────────┬─────────────────────────────────┬───────────────────┘
│ electron-trpc IPC │ HTTP/WS
▼ ▼
┌─────────────────────────┐ ┌────────────────────────────────────┐
│ LOCAL SQLITE │ │ HOST-SERVICE (per org) │
│ (~/.superset/) │ │ separate Node process, survives │
│ │ │ app restarts via manifest.json │
│ LOCAL-ONLY: │ │ │
│ - projects │ │ OWN SQLITE: │
│ - worktrees │ │ - terminalSessions │
│ - workspaces │ │ - projects (repo metadata) │
│ - settings │ │ - pullRequests (cache) │
│ - browserHistory │ │ - workspaces (worktree paths) │
│ │ │ │
│ SYNCED (Electric): │ │ EVENTS (WebSocket EventBus): │
│ - users │ │ - git:changed (broadcast, 300ms) │
│ - organizations │ │ - fs:events (per-subscriber) │
│ - tasks │ │ │
└──────────────────────────┘ └────────────────────────────────────┘
```
## State ownership map
Superset distributes state across **four separate stores**, each with a
clear owner and sync strategy.
### 1. Cloud Postgres (source of truth for shared data)
Owner: API server. Written via tRPC mutations from any client.
**Auth tables** (`auth.*`): users, sessions, accounts, organizations,
members, invitations, OAuth clients/tokens/consents, API keys, device codes,
JWKs, verifications.
**Public tables:**
- `tasks` — full task data with Linear/GitHub sync (`external_provider`,
`external_id`), assignee, status FK, labels JSON
- `taskStatuses` — per-org customizable statuses with position/color/type
- `integrationConnections` — OAuth tokens for Linear/GitHub/Slack per org
- `subscriptions` — Stripe billing
- `devicePresence` — online devices with `lastSeenAt` for command routing
- `agentCommands` — the CLI→desktop command queue (see below)
- `projects` — cloud-side project registry (org-scoped, GitHub-linked)
- `workspaces` — cloud workspaces, type enum `"local" | "cloud"`, JSON config
- `secrets` — encrypted environment variables per project
- `sandboxImages` — Dockerfile-like config per project
- `chatSessions` + `sessionHosts` — chat session tracking
- `usersSlackUsers` — Slack user mapping
**v2 tables** (newer architecture):
- `v2Projects`, `v2Hosts`, `v2Clients`, `v2UsersHosts`, `v2Workspaces`
**GitHub tables:**
- `githubInstallations`, `githubRepositories`, `githubPullRequests`
**Ingest tables** (`ingest.*`):
- `webhookEvents` — raw payloads from Linear/GitHub/Slack with processing
state machine (`pending`/`processed`/`failed`/`skipped`)
### 2. Desktop local SQLite (~/.superset/local.db)
Owner: Electron main process. No cloud sync — purely local state.
**Local-only tables** (camelCase columns):
- `projects` — git repos the user opened (path, name, color, tabOrder,
defaultBranch, githubOwner, neonProjectId)
- `worktrees` — git worktrees within a project (path, branch, baseBranch,
gitStatus JSON, githubStatus JSON, createdBySuperset flag)
- `workspaces` — active workspaces, type `"worktree"|"branch"`, tabOrder,
deletingAt, portBase, sectionId
- `workspaceSections` — user-created groups for organizing workspaces
- `settings` — singleton row: terminal presets, agent presets, ringtone,
font settings, branch prefix mode
- `browserHistory` — URL autocomplete with visit counts
**Synced tables** (snake_case columns matching Postgres exactly):
- `users` — id, clerk_id, name, email, avatar_url
- `organizations` — id, clerk_org_id, name, slug, github_org
- `organizationMembers` — organization_id, user_id, role
- `tasks` — full task data mirrored from cloud
The naming convention IS the boundary marker: **camelCase = local-only,
snake_case = synced from cloud via Electric SQL.**
### 3. Host-service SQLite (~/.superset/host/{orgId}/host.db)
Owner: host-service process (one per org). Separate from desktop local-db.
- `terminalSessions` — PTY lifecycle tracking (id, status, originWorkspaceId)
- `projects` — repo metadata for host-service's own use
- `pullRequests` — cached PR state (number, branch, SHA, review decision, checks)
- `workspaces` — worktree path mapping for terminal/git operations
Uses `better-sqlite3` with WAL journal mode and foreign keys enabled.
### 4. Renderer localStorage (client-side only)
Owner: Electron renderer process. No sync.
- `v2SidebarProjects` — expansion/collapse state
- `v2WorkspaceLocalState` — last pane layout
- `v2SidebarSections` — sidebar grouping
These use `localStorageCollectionOptions` with Zod schemas in TanStack DB.
---
## Data flow paths
### Read path (cloud → client)
```
Postgres (source of truth)
→ Postgres logical replication (WAL tailing)
→ Electric SQL service (Elixir, shapes per table)
→ Electric Proxy (Cloudflare Worker)
- JWT auth against JWKS endpoint
- Injects WHERE org_id = ? per table (row-level security)
- Strips sensitive columns (tokens, secrets)
- Forwards Electric protocol params (live, offset, cursor)
→ TanStack Electric Collection (in-memory reactive store)
→ useLiveQuery() in React components
```
**22 Electric shape subscriptions** per organization on desktop:
tasks, taskStatuses, projects, v2Projects, v2Hosts, v2Clients,
v2UsersHosts, v2Workspaces, workspaces, members, users, invitations,
agentCommands, integrationConnections, subscriptions, apiKeys,
chatSessions, sessionHosts, githubRepositories, githubPullRequests,
plus organizations (global, not org-scoped).
**Mobile subscribes to only 6 shapes**: tasks, taskStatuses, projects,
members, users, invitations. Auth via cookies instead of Bearer tokens.
Routes through API server (`/api/electric/v1/shape`) not dedicated proxy.
### Write path (client → cloud)
```
Client UI action
→ optimistic local update (TanStack Collection)
→ tRPC mutation to cloud API
→ Postgres INSERT/UPDATE
→ Electric SQL WAL tail picks it up
→ shape stream to all subscribers
→ TanStack Collection confirms via txid (resolves optimistic write)
```
Writes are **asymmetric** — they go through tRPC, not Electric. Electric
is read-only. The `txid` return value from tRPC mutations lets TanStack
know when the server has confirmed the optimistic local update.
### Real-time events path (host-service → clients)
```
Git directory change on disk
→ fs.watch(gitDir, { recursive: true }) in GitWatcher
→ 300ms debounce
→ EventBus.broadcast({ type: "git:changed", workspaceId })
→ WebSocket to all connected clients
→ useGitChangeEvents() triggers React Query invalidation
```
WebSocket EventBus runs inside host-service. Two message types:
- `git:changed` — broadcast to ALL clients, driven by GitWatcher
- `fs:events` — on-demand per-client, ref-counted subscriptions
Client-side: single WebSocket per hostUrl (singleton), auto-reconnect
with exponential backoff (1s base, 30s max), re-sends all `fs:watch`
subscriptions on reconnect.
GitWatcher rescans DB for workspaces every 30 seconds to auto-discover
new workspaces and drop watchers for removed ones.
### Command queue path (CLI/MCP → desktop)
```
MCP tool / CLI command / Slack bot
→ tRPC mutation: INSERT INTO agent_commands (status='pending',
tool, params, targetDeviceId, timeoutAt)
→ Electric SQL syncs row to desktop's agentCommands collection
→ useLiveQuery filters: status="pending" AND targetDeviceId matches
→ executeTool(tool, params) runs locally on desktop
→ collection.update(id, { status: 'completed', result })
→ tRPC mutation writes result back to Postgres
→ Electric confirms, MCP server sees completion
```
**Desktop side is reactive** (via Electric SQL `useLiveQuery`), not polling.
MCP server side polls every 500ms waiting for status change.
Available tools executed on desktop: createWorkspace, deleteWorkspace,
getAppContext, getWorkspaceDetails, listProjects, listWorkspaces,
startAgentSession, startAgentSessionWithPrompt, switchWorkspace,
updateWorkspace.
### Host-service durability path
```
Spawn: Electron forks Node process with ELECTRON_RUN_AS_NODE=1
→ child sends { type: "ready", port } via IPC
→ parent writes manifest to ~/.superset/host/{orgId}/manifest.json
(pid, endpoint, authToken, serviceVersion, protocolVersion, startedAt)
→ manifest written with mode 0o600 (owner-only)
Restart: discoverAndAdoptAll() scans ~/.superset/host/ for manifests
→ isProcessAlive(pid) via process.kill(pid, 0)
→ health check: GET {endpoint}/trpc/health.check (3s timeout)
→ protocol version check
→ if all pass: adopt (no new process)
Normal quit: releaseAll() detaches IPC, leaves process running
→ KEEP_ALIVE_AFTER_PARENT=1 env var keeps host-service alive
→ manifest stays on disk for next launch to adopt
Crash: liveness poll every 5s for adopted processes
→ if dead: mark "degraded", schedule restart
→ exponential backoff: min(1000 * 2^restartCount, 30000)ms
```
---
## Electric Proxy detail
The Cloudflare Worker at `apps/electric-proxy/` handles:
**Auth**: Client sends `Authorization: Bearer <JWT>`. Proxy verifies
against app's JWKS endpoint (`/api/auth/jwks`) using `jose`. JWT contains
`sub`, `email`, and `organizationIds[]`.
**Row-level security**: Builds parameterized WHERE clause per table:
```
tasks → WHERE organization_id = $1
agent_commands → WHERE organization_id = $1
auth.users → WHERE $1 = ANY(organization_ids)
auth.apikeys → WHERE metadata LIKE '%organizationId:$1%'
```
20+ tables, each with org-scoped filtering.
**Column restrictions**: Strips sensitive columns:
```
auth.apikeys → only: id, name, start, created_at, last_request
integration_connections → excludes: accessToken, refreshToken
```
**Protocol forwarding**: Passes Electric params (`live`, `handle`,
`offset`, `cursor`) and injects source credentials (`ELECTRIC_SOURCE_ID`
/ `ELECTRIC_SOURCE_SECRET`).
---
## Collections caching
Desktop caches collections per org (`collectionsCache: Map<string,
OrgCollections>`). Collections can be eagerly preloaded via
`preloadCollections()`. Each workspace gets its own
`WorkspaceClientProvider` with a separate `QueryClient` (5s stale time,
30min GC time).
+196
View File
@@ -0,0 +1,196 @@
# Superset Terminal State Sync
Deep dive completed 2026-04-08 from `github.com/superset-sh/superset`.
## Two architectures
Superset has **two coexisting terminal architectures**:
- **V1 (desktop daemon)** — mature path for local terminals
- **V2 (host-service WebSocket)** — simpler path for remote/cloud workspaces
```
V1 (local):
PTY subprocess ──[5-byte frame protocol]──> Daemon process
──[Unix domain socket, NDJSON]──> Electron main
──[Electron IPC, tRPC subscriptions]──> Renderer ──> xterm.js
V2 (remote):
PTY (node-pty) ──[direct]──> Host-service process
──[WebSocket, JSON messages]──> Renderer ──> xterm.js
```
Both stream data in real-time — no polling.
## V1: Desktop daemon path
### Data flow
1. PTY runs in a **separate child process** (`pty-subprocess.js`),
isolating blocking PTY I/O from Electron's event loop.
2. Subprocess sends framed binary messages via **stdout** using a custom
frame protocol — 5-byte header (1 byte type + 4 byte length):
```
Frame types: Ready, Spawned, Data, Exit, Error,
Spawn, Write, Resize, Kill, Signal, Dispose
```
3. `Session` class decodes frames and broadcasts to attached clients via
**Unix domain sockets** (`~/.superset/terminal-host.sock`) using NDJSON.
Auth via shared token from `terminal-host.token`.
4. `TerminalHostClient` maintains **two sockets** per daemon:
- `controlSocket` — request/response RPC
- `streamSocket` — unidirectional event streaming
5. Electron main process relays events to renderer via **tRPC subscriptions**
over Electron IPC.
### HeadlessEmulator (the clever bit)
A headless xterm instance (`@xterm/headless` + `@xterm/addon-serialize`)
runs in the daemon, mirroring all PTY output. Tracks:
- Full screen state with 5000-line scrollback
- 14 terminal mode flags (DECSET/DECRST)
- CWD (via OSC-7)
- Alternate screen buffer state
On re-attach, produces a `TerminalSnapshot`:
```
{
snapshotAnsi, // serialized screen via @xterm/addon-serialize
rehydrateSequences, // mode-restoring escape sequences
modes, // 14 tracked terminal mode flags
cwd, // last known working directory
dimensions // cols × rows
}
```
This enables faithful restoration of TUI apps (vim, htop) including
alternate screen buffer, bracketed paste mode, mouse tracking, etc.
### Backpressure
- Tracks per-client socket drain state
- Pauses PTY stdout reads when emulator write queue exceeds **1MB**
(`EMULATOR_WRITE_QUEUE_HIGH_WATERMARK_BYTES`)
- Resumes at **250KB**
- Subprocess stdin has a **2MB** queue cap
## V2: Host-service WebSocket path
### Data flow
1. PTY spawned directly via `node-pty.spawn()` in host-service process.
2. Output streamed via WebSocket at `/terminal/:terminalId`. JSON messages:
```
Server → Client: { type: "data"|"replay"|"error"|"exit", data: "..." }
Client → Server: { type: "input"|"resize"|"dispose", ... }
```
3. WebSocket upgraded via `@hono/node-ws` in the Hono HTTP server.
### PTY lifetime is independent of WebSocket lifetime
Explicit design: "PTY lifetime is independent of socket lifetime — sockets
detach/reattach freely."
- When WebSocket disconnects, PTY keeps running
- Output buffered in **64KB in-memory ring buffer** (`MAX_BUFFER_BYTES`)
- On reconnect, `replayBuffer()` sends all buffered output as a single
`{ type: "replay", data: "..." }` message
- Auto-reconnect: exponential backoff (500ms base, 10s max, 10 attempts)
### Terminal session lifecycle
`terminalSessions` table in host-service SQLite:
```sql
terminal_sessions (
id TEXT PRIMARY KEY,
origin_workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'active', -- active | exited | disposed
created_at INTEGER NOT NULL,
last_attached_at INTEGER,
ended_at INTEGER
)
```
Lifecycle:
1. **Create**: `POST /terminal/sessions` or first WebSocket to
`/terminal/:terminalId`. Spawns PTY, inserts row, adds to in-memory Map.
2. **Attach/detach**: WebSocket open/close. Multiple connections to same
terminal displaces old socket (close code 4000). Updates `lastAttachedAt`.
3. **Exit**: PTY `onExit` → `status: 'exited'`, sets `endedAt`.
4. **Dispose**: `DELETE /terminal/sessions/:terminalId` or `{ type: "dispose" }`
message. Kills PTY, removes from Map, `status: 'disposed'`.
5. **List**: `GET /terminal/sessions` returns live sessions with `terminalId`,
`exited`, `exitCode`, `attached` status.
## Multi-pane management
Each terminal is identified by a `terminalId`. The panes system
(`@superset/panes`) is a Zustand store with a **binary tree layout model**.
```
Tab
└── layout: LayoutNode (binary tree of splits)
└── panes: Record<string, Pane>
└── kind: "terminal"
└── data: { terminalId: "..." }
```
**`TerminalRuntimeRegistry`** — singleton managing per-terminal entries:
- Each entry contains a `TerminalRuntime` (xterm instance + addons) and
a `TerminalTransport` (WebSocket connection)
- Keyed by `terminalId`
- Pane mount → `registry.attach(terminalId, container, wsUrl, appearance)`
- Pane unmount → `registry.detach(terminalId)`
**Runtime persists independently of the DOM.** On detach, xterm buffer is
serialized to `localStorage` (up to 1000 lines). On re-attach, restored
from localStorage and DOM wrapper re-appended. Multiple panes have
completely independent terminals, WebSockets, and xterm instances.
## Scrollback preservation — 5 layers
| Layer | Location | Size | Purpose |
|-------|----------|------|---------|
| xterm.js scrollback | Renderer | 5000 lines | Live buffer in the UI |
| HeadlessEmulator | V1 daemon | 5000 lines | Authoritative source for session restoration |
| Ring buffer | V2 host-service | 64KB | Captures output while no WebSocket connected |
| localStorage | Renderer | 1000 lines | Survives pane detach/re-mount |
| Cold restore snapshot | V1 daemon | Full screen | Restores TUI apps with escape sequences |
**Cold restore** (after daemon/app restart): scrollback shown as read-only
with a "Session Contents Restored" separator. User can start a new shell
in the same CWD. Handled by `useTerminalColdRestore` hook.
## Key files
- `apps/desktop/src/main/terminal-host/session.ts` — V1 session management
- `apps/desktop/src/main/lib/terminal-host/client.ts` — V1 daemon client
- `apps/desktop/src/main/lib/terminal-host/headless-emulator.ts` — HeadlessEmulator
- `packages/host-service/src/terminal/terminal.ts` — V2 terminal lifecycle
- `packages/host-service/src/db/schema.ts` — terminalSessions table
- `apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts` — runtime registry
- `apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts` — V2 WebSocket transport
- `packages/panes/src/core/store/store.ts` — pane layout store
## Happy takeaways
- The V1 subprocess isolation pattern (PTY in child process, frame protocol
over stdout) is worth considering if Happy ever runs PTY directly — keeps
the main process responsive.
- HeadlessEmulator for faithful TUI restoration is clever — `@xterm/headless`
+ `@xterm/addon-serialize` enables restoring vim/htop state including
alternate screen buffer and terminal modes.
- The 64KB ring buffer for reconnection replay (V2) is a simple, effective
pattern — no complex journaling, just buffer recent output and replay on
reconnect.
- PTY lifetime decoupled from UI lifetime is the right design — matches
how Happy's daemon already works.
- localStorage for renderer-side scrollback persistence across pane
remounts is a cheap win.
+440
View File
@@ -0,0 +1,440 @@
# Happy User Deep Dive — Full Analysis
*Generated 2026-04-12. Data: 502 unique contributors, 497 issues, 296 PRs, 17,782 stars.*
---
## Executive Summary
Happy has 502 unique external contributors across 793 issues+PRs. The userbase is **truly global** — roughly 35% East Asian (China, Japan, Korea, HK), 40% Western (US, Europe, AU), 15% South/Southeast Asian, 10% other. Activity peaked in mid-February 2026 (43 new users/week) and has stabilized at ~27 new users/week.
**Founders:** Kirill Dubovitskiy (bra1nDump), Steve Korshakov (ex3ndr / ex3ndr-bot), and the GrocerPublishAgent system (PeoplesGrocers LLC). All three are excluded from "external contributor" counts below unless otherwise noted.
**The most striking finding:** Happy's contributor list reads like a who's-who of developer tools. Instagram's first engineer, OpenAI's ChatGPT team, Meta EMs, CMU robotics professors, the Kubeflow co-founder, Meteor/Apollo core engineers, the ReactiveUI creator, LAION/img2dataset builders, and the KernelSU creator (16K stars) all use or contribute to Happy.
---
## Growth Timeline
```
Week New Users Cumulative
2025-07-21: 2 2 ██
2025-08-18: 2 5 ██
2025-09-01: 3 9 ███
2025-11-03: 8 23 ████████ ← First real growth
2025-11-17: 12 38 ████████████
2025-12-29: 28 112 ████████████████████████████ ← Holiday spike
2026-01-12: 24 159 ████████████████████████
2026-02-09: 42 275 ██████████████████████████████████████████ ← PEAK
2026-02-16: 43 318 ███████████████████████████████████████████
2026-03-30: 31 475 ███████████████████████████████
2026-04-06: 27 502 ███████████████████████████
```
**Key inflection points:**
- **Jul-Sep 2025:** Founding era. 12 users. Mostly direct network (dvlkv, vzhovnitsky, kamal, etc.)
- **Nov 2025:** First real growth wave (~35 new users). Product discovered by broader audience.
- **Dec 2025-Jan 2026:** Steady growth. Self-hosting & Docker interest emerges.
- **Feb 2026:** EXPLOSIVE growth. 150+ new users in one month. Peak engagement.
- **Mar-Apr 2026:** Maturation. Growth stabilizes but still healthy (~27/week).
---
## Monthly Activity
| Month | Issues | PRs | Total | Unique Users |
|----------|--------|-----|-------|-------------|
| 2025-07 | 0 | 3 | 3 | 3 |
| 2025-08 | 0 | 4 | 4 | 3 |
| 2025-09 | 0 | 8 | 8 | 6 |
| 2025-10 | 0 | 2 | 2 | 1 |
| 2025-11 | 36 | 4 | 40 | 36 |
| 2025-12 | 44 | 27 | 71 | 51 |
| 2026-01 | 103 | 45 | 148 | 113 |
| 2026-02 | 175 | 105 | 280 | 158 |
| 2026-03 | 88 | 74 | 162 | 115 |
| 2026-04 | 51 | 24 | 75 | 55 |
---
## Contributor Type Distribution
- **Issue reporters only:** 352 (70%) — Users who found bugs / requested features
- **PR authors only:** 115 (23%) — Code contributors
- **Both issues & PRs:** 35 (7%) — Deeply engaged contributors
---
## Contribution Topic Areas
| Topic | Count | Notes |
|----------------------|-------|-------|
| Session Management | 158 | #1 concern — daemon, tmux, lifecycle |
| Mobile/iOS/Android | 116 | Core mobile UX |
| Codex Integration | 99 | Multi-agent support (Codex, Gemini, Kimi, OpenCode) |
| UI/UX | 80 | Buttons, sidebar, scroll, layout |
| MCP/Protocol | 49 | MCP server compat, ACP protocol |
| Voice/Audio | 31 | ElevenLabs, Whisper, voice agents |
| Windows | 29 | Platform compatibility |
| Docker/Deploy | 20 | Self-hosting |
| Security | 20 | XSS, CORS, encryption, auth |
| i18n/Languages | 16 | Chinese, Japanese, Italian, Korean |
| Self-hosting | 15 | Docker compose, local deployment |
| Performance | 6 | N+1 queries, latency |
---
## Retention Analysis
Only **28 of 502** contributors (5.6%) returned in a second month. The power users who stuck around:
| User | Months Active | Span |
|------|--------------|------|
| LightYear512 | 4 months | Jan-Apr 2026 |
| denysvitali | 4 months | Sep 2025-Feb 2026 |
| ahundt | 4 months | Nov 2025-Mar 2026 |
| theflysurfer | 3 months | Jan-Mar 2026 |
| chaehyun2 | 3 months | Feb-Apr 2026 |
| cruzanstx | 3 months | Nov 2025-Apr 2026 |
| kmizzi | 3 months | Nov 2025-Feb 2026 |
| bbhxwl | 3 months | Jan-Apr 2026 |
---
## Community Engagement (Issue Comments)
Beyond filing issues/PRs, these people are the actual *community* — they discuss, review, and help others:
| User | Comments | Role |
|------|----------|------|
| bra1nDump (Kirill Dubovitskiy) | 104 | Co-founder |
| ex3ndr (Steve Korshakov) | 90 | Co-founder |
| GrocerPublishAgent (PeoplesGrocers) | 71 | Co-founder (automated agent) |
| **leeroybrun** | **40** | Community champion |
| happier-bot | 20 | Bot |
| **Miista (Søren Guldmund)** | **13** | Discussion participant |
| **ahundt (Andrew Hundt, CMU)** | **13** | Academic contributor |
| **MattStarfield** | **12** | Hardware engineer |
| **hyacz** | **12** | Chinese community builder |
| **tiann (weishu)** | **10** | KernelSU creator |
| **rrnewton (Ryan Newton, Meta/Purdue)** | **10** | CS researcher |
---
## ⭐ The VIP Wall — Notable Users by Profile
### Founders
**ex3ndr (Steve Korshakov)** — Bay Area — 1,099 followers — **90 comments**
- "Chaotic good inventor." Working on AGI, social, fintech, new languages
- Created **llama-coder** (2,082 stars) — local AI Copilot replacement
- Core contributor to **Nicegram** (Telegram alternative client, 681+338 stars)
- Created **Tact** smart contract language for TON blockchain (694 stars)
- Building VALL-E 2 and VoiceBox neural network reproductions
- Also runs **ex3ndr-bot** submitting automated PRs
- Happy activity: 2 PRs + 9 bot PRs + 90 comments.
**bra1nDump (Kirill Dubovitskiy)** — Bay Area — Co-founder
- Previously at Robinhood (AI) and Meta
- Happy activity: 104 comments, core maintainer.
**GrocerPublishAgent** — PeoplesGrocers LLC — Co-founder (automated agent)
- Agentic CI/CD system operated by PeoplesGrocers LLC
- Happy activity: 4 merged PRs + 71 comments. Handles mono repo refactoring, win32 bundling, e2e testing.
---
### Tier S — Industry Legends
**anaisbetts (Ani Betts)** — Berlin — 2,428 followers
- Created **ReactiveUI** (8,469 stars), .NET reactive programming framework
- Created **Akavache** (2,543 stars) and **ModernHttpClient** (656 stars)
- Former GitHub engineer, worked on **Electron** (246 commits) and **GitHub Desktop**
- Now building MCP tools: **mcp-installer** (1,519 stars), **mcp-youtube** (514 stars)
- Happy activity: 1 issue (Nov 2025). Early user.
**rom1504 (Romain Beaumont)** — Palo Alto — 2,228 followers
- ML Engineer at **Google**
- Created **img2dataset** (4,407 stars) — THE tool for building AI training datasets
- Created **clip-retrieval** (2,749 stars)
- Key figure in **LAION** ecosystem (Open-Assistant: 37,419 stars)
- Happy activity: 1 issue (Jan 2026).
**shayne (Shayne Sweeney)** — NYC — 360 followers
- **ChatGPT team at OpenAI**
- Ex-**Tailscale** Product
- **First Engineer at Instagram**
- Created **go-wsl2-host** (1,679 stars), **wsl2-hacks** (1,312 stars)
- Happy activity: 4 issue comments. Lurker-engager.
**glasser (David Glasser)** — Berkeley — 519 followers
- Core engineer at **Apollo GraphQL** (apollo-server: 13,937 stars)
- **3,978 commits to Meteor** (44,781 stars) — one of its most prolific contributors ever
- Happy activity: 1 issue (Jan 2026).
**aronchick (David Aronchick)** — Seattle — 182 followers
- Co-founder of **Kubeflow** (15,568 stars) — THE ML toolkit for Kubernetes
- Previously led Kubernetes/ML at Google
- Now CEO of **Expanso** (Bacalhau, 854 stars)
- Happy activity: 2 contributions (Jan 2026), including a PR.
**tiann (weishu)** — Hong Kong — 8,218 followers
- Creator of **KernelSU** (15,979 stars) — kernel-based Android root solution
- Also runs **hapi** (3,451 stars) — a competing/parallel mobile AI coding client!
- Created **Leoric** (1,918 stars), **eadb** (555 stars)
- Happy activity: 5 PRs (3 merged, Dec 2025). Android fixes. Also 10 comments.
---
### Tier A — Well-Known in Their Domain
**danielamitay (Daniel Amitay)** — NYC — 401 followers
- OG iOS developer legend
- Created **DACircularProgress** (2,348 stars), **DAKeyboardControl** (1,551 stars), **iHasApp** (1,431 stars)
- 6,500+ cumulative stars across iOS libraries
- Happy activity: 1 merged PR (Jan 2026) — swipe to archive/delete sessions.
**rsanheim (Rob Sanheim)** — Madison, WI — 250 followers
- **Former GitHub engineer**
- Former **Cognitect** (the company behind Clojure/Datomic)
- Now at Doximity. Ruby/Rails community veteran since 2008.
- Happy activity: 1 PR (Feb 2026) — removing credential logging.
**rrnewton (Ryan Newton)** — Indiana — 287 followers
- Computer Scientist at **Meta** and **Purdue University** (and Indiana University)
- Specializes in Containers, Compilers, Deterministic Parallelism
- Created **happy-devbox** (30 stars) — dev environment for Happy
- Happy activity: 1 PR + 10 comments.
**anaclumos (Sunghyun Cho)** — 0 followers (deliberately hidden)
- Created **bing-chat-for-all-browsers** (1,444 stars)
- Created **heimdall** (130 stars), **extracranial** (146 stars)
- Deliberately anonymous profile ("not interested in becoming famous")
- Happy activity: 1 issue (Nov 2025). Very early.
**denysvitali (Denys Vitali)** — Zurich — 520 followers
- Prolific hacker/reverse engineer. 484 repos.
- Created **thebestmotherfuckingwebsite** (675 stars), **nginx-error-pages** (425 stars)
- Tesla firmware decryption, Apple FindMy key ops, COVID cert analysis
- Happy activity: 4 contributions spanning Sep 2025-Feb 2026. Very early, very persistent.
**krzemienski (Nick Krzemienski)** — NYC — 228 followers
- Video Engineer, formerly Engineering Lead at **fuboTV**
- Created **awesome-video** (1,845 stars) — definitive streaming video resource
- 1,325 repos. Also building Claude Code iOS tools obsessively.
- Happy activity: 3 PRs (Sep-Oct 2025). VERY early. First external feature contributor.
---
### Tier B — Impressive Professionals
**dzlobin (Danny Zlobinsky)** — NYC — 113 followers
- **Engineering Manager at Meta/Facebook**
- Happy activity: 5 contributions (Feb 2026). PRs: camera dismiss, Ink rendering, tmux detach.
**omachala (Ondrej Machala)** — London — 11 followers
- **Engineering Lead at JPMorgan Chase**
- Created **ha-treemap-card** (57 stars), **heroshot** (35 stars)
- Happy activity: 6 issues (Feb 2026). Thoughtful UX-focused bug reports.
**mfazekas (Miklos Fazekas)** — Hungary — 142 followers
- Freelance React Native expert
- **Lead maintainer of rnmapbox/maps** (2,810 stars)
- Also maintains net-ssh (Ruby SSH library)
- Happy activity: 1 PR (Feb 2026) — PTY proxy stdin corruption fix.
**lucharo (Luis Chaves Rodriguez)** — London — 35 followers
- Data scientist/bioinformatician at **GSK (GlaxoSmithKline)**
- MSc Health Data Analytics & Machine Learning
- Happy activity: 7 contributions (Feb 2026). UX bug reports.
**brtkwr (Bharat)** — Bristol, UK — 52 followers
- Security engineer at **Two Inc** (B2B payments fintech)
- Happy activity: 7 security issues in ONE DAY (Feb 19). Full security audit: QR auth expiry, wildcard CORS, debug logs, Docker root, RevenueCat bypass.
**arthurgervais (Arthur Gervais)** — 97 followers
- Blockchain security researcher
- Created **Bitcoin-Simulator** (196 stars), **MAPTA** (99 stars)
- Building AI-driven automated security testing (XBOW)
- Happy activity: 1 issue (Jan 2026).
**ahundt (Andrew Hundt)** — Pittsburgh — 505 followers
- **Carnegie Mellon University** CIFellow, PhD Johns Hopkins
- Previously at CMU's National Robotics Engineering Center
- Created **awesome-robotics** (1,365 stars)
- Happy activity: 5 contributions spanning Nov 2025-Mar 2026. 13 comments.
**jonocodes (Jono)** — San Francisco — 41 followers
- Works at **Terradot** (climate tech / carbon removal)
- Created **savr** (113 stars), GtkSourceSchemer, GeditSplitView
- Happy activity: 6 contributions (Mar 2026). Server status, web errors, UX.
---
### Tier C — Community Champions (Not Famous but Incredibly Valuable)
**leeroybrun (Leeroy Brun)** — Lausanne, Switzerland — 69 followers
- Full-stack engineer at **Batiplus**
- **Built the entire Happy self-hosted ecosystem:** happy-stacks, happy-server-light, happy-cli, slopus.github.io (docs)
- 5 merged PRs + 40 issue comments. THE community champion.
- Also built tools for Codex and OpenCode.
*(ex3ndr and GrocerPublishAgent are co-founders — see Founders section above)*
**cruzanstx** — Possibly US Virgin Islands — 9 followers
- The LONGEST-TENURED active user: Nov 2025 to Apr 2026 (5+ months!)
- Built **cclimits** (15 stars) — check quota for Claude Code, Codex, Gemini CLI
- Also built own **hapi** fork and **happy-cli** fork
- 6 issues spanning 5 months. The most persistent power user.
**theflysurfer** — ??? — 0 followers, 0 repos
- Ghost account. Filed 22 issues (most of any user!) covering i18n, image upload, sessions, pkgroll
- 18 issues in a SINGLE DAY (Feb 24, 2026). Insane thoroughness.
- Complete mystery — no profile, no repos, no followers. Just issues.
**nikhilsitaram** — ??? — 0 followers
- Brand new account (Dec 2025), but created **claude-caliper** (68 stars) and **claude-memory-system** (12 stars)
- 7 contributions focused on MCP/infrastructure
- Possibly experienced dev with a fresh account for AI tooling work.
**Scoteezy (Denis Bondarenko)** — Russia — 9 followers
- **100% merge rate!** All 4 PRs merged.
- Windows fixes, claude-agent-sdk migration, yolo mode, Gemini ACP integration
- The most versatile and reliable external contributor.
**hztBUAA (Zhenting Huang)** — Beijing — 30 followers
- CS senior at **Beihang University** (BUAA). 99 public repos.
- Building Obsidian plugins, AI desktop apps, MCP tools, LLM security scanners
- 10 contributions. Technically ambitious for a student.
**SaneBow** — Hong Kong — 49 followers
- Security + ML researcher
- Created **PiDTLN** (93 stars, ML noise suppression), **redirect-fuzzer** (53 stars)
- 2 merged PRs: AskUserQuestion UI, collapsible sidebar.
**EricSeastrand** — Houston, TX — 6 followers
- Building Claude Code tooling ecosystem: claude-context, claude-usage, claude-vectorsearch
- 9 PRs in 2 days. Mobile web fixes + Prometheus metrics. 1 merged.
**kmizzi (Kalvin Mizzi)** — Los Angeles — 6 followers
- Fintech developer. **Polymarket arbitrage bot** (10 stars), automated trading
- One of the EARLIEST external contributors (Nov 2025). 6 contributions over 3 months.
---
## The Pioneers (Pre-November 2025)
These 12 people were using Happy before it had any real traction:
| User | First Activity | What They Did |
|------|---------------|---------------|
| expectfun (Eugene Trifonov) | Jul 21, 2025 | Fixed a typo. Literally the first external contributor. |
| vzhovnitsky (Vladislav) | Jul 22, 2025 | iOS permissions check |
| dvlkv (Dan Volkov) | Jul 28, 2025 | Android barcode scanner fix |
| turbocrime | Aug 22, 2025 | Modal.prompt Android fix |
| **kamal (Kamal Fariz Mahyuddin)** | Aug 22, 2025 | Submit button + staged file path fixes. Bay Area Ruby veteran. |
| lava (Benno Evers) | Aug 26, 2025 | Voice assistant incoming messages |
| pyflmcp | Sep 3, 2025 | Simplified Chinese language pack |
| JulianCrespi | Sep 7, 2025 | iOS file picker |
| Lesiuk (Damian Lesiuk) | Sep 13, 2025 | GLM coding plan compatibility |
| **denysvitali (Denys Vitali)** | Sep 23, 2025 | New session wizard. Zurich hacker, 520 followers. |
| **krzemienski (Nick Krzemienski)** | Sep 30, 2025 | Resource browser, model control, Sonnet 4.5 support. Video industry leader. |
| GrocerPublishAgent | Sep 2025 | Co-founder (automated agent) |
---
## External Contributors with Merged PRs (Code That Shipped)
42 external contributors have had code merged. Top by volume:
| Contributor | Merged PRs | Key Contributions |
|------------|-----------|-------------------|
| leeroybrun | 5 | Expo app fixes, TypeScript CI, Enter-to-send, new session UX |
| Scoteezy | 4 | Windows, SDK migration, yolo mode, Gemini ACP. 100% merge rate. |
| GrocerPublishAgent | 4 | win32 bundling, mono repo, session hiding, markdown copy |
| tiann | 3 | Android: first message, press, notifications |
| LightYear512 | 2 | Windows npm shims, windowsHide |
| hyacz | 2 | Markdown layout, CLAUDE.md docs |
| OrdinarySF | 2 | Shell wrapper, IME composition |
| kmizzi | 2 | Message fetch, encryption retry |
---
## Burst Contributors (The "Speed Runners")
Some people go absolutely ham in a single day:
| User | Items | Date | What Happened |
|------|-------|------|--------------|
| theflysurfer | 18 | Feb 24 | Filed 18 detailed issues in one day |
| davidrimshnick | 12 | Feb 21 | 12 PRs, 0 merged. Possibly AI-generated burst. |
| HirokiKobayashi-R | 10 | Feb 13 | 10 contributions. Go/infra engineer. |
| seibe | 9 | Feb 21 | 9 PRs. Japanese Haxe/WebRTC dev in Tokyo. |
| EricSeastrand | 8 | Mar 13 | 8 PRs. Mobile web + Prometheus. Houston dev. |
| brtkwr | 7 | Feb 19 | 7 security issues. Full audit in one sitting. |
| nikhilsitaram | 7 | Feb 16 | 7 contributions. MCP/infra focus. |
---
## Still Active (April 2026)
55 users active in April 2026. Notable returnees from earlier cohorts:
- **cruzanstx** — Active since Nov 2025 (5+ months!)
- **LightYear512** — Active since Jan 2026 (4 months), Windows champion
- **Scoteezy** — Active since Jan 2026, still shipping merged PRs
- **chaehyun2** — Active since Feb 2026, frontend contributions
- **bbhxwl** — Active since Jan 2026
---
## Geographic Distribution (Inferred)
**East Asia (~35%):** China (Beijing, Wuhan, Shanghai, HK), Japan (Tokyo), Korea, Taiwan
**North America (~30%):** SF Bay Area, NYC, Houston, LA, Seattle, Pittsburgh, El Paso
**Europe (~25%):** London, Zurich/Lausanne, Bristol, Hungary, Russia, Czech Republic, Greece
**Other (~10%):** Israel, India, Vietnam, Brazil, USVI
**Notable clusters:**
- Bay Area: ex3ndr, kamal, jonocodes, rom1504
- NYC: dzlobin, danielamitay, krzemienski, fny
- London: omachala (JPMorgan), lucharo (GSK), brtkwr (Two Inc)
- Beijing: hztBUAA, nullne
- Hong Kong: tiann, SaneBow
---
## User Archetypes
1. **The Power User** (cruzanstx, theflysurfer, omachala) — Files many issues over time, deeply knows the product, shapes UX
2. **The Code Contributor** (leeroybrun, Scoteezy, tiann) — Submits PRs that get merged, ships features
3. **The Security Auditor** (brtkwr) — Does a comprehensive security sweep in one session
4. **The Ecosystem Builder** (leeroybrun, chris-yyau, EricSeastrand) — Builds tools, docs, and infrastructure around Happy
5. **The Sprint Contributor** (davidrimshnick, seibe, EricSeastrand) — Burst of activity in 1-2 days
6. **The Silent VIP** (shayne, glasser, anaisbetts, rom1504) — Famous people who lurk, comment, or file 1 issue
7. **The Student Builder** (hztBUAA, chaehyun2) — Early career devs contributing to build their portfolio
8. **The Competing Builder** (tiann/hapi, cruzanstx/hapi) — People who also build similar products
---
## Key Takeaways
1. **You have genuine industry credibility.** Instagram's first engineer, OpenAI, Meta EMs, CMU professors, Apollo/Meteor core engineers, ReactiveUI creators, and Google ML researchers all use Happy. This is not a toy project.
2. **Steve Korshakov (ex3ndr) is your #1 community asset.** 90 comments, 11 PRs (via bot), plugin architecture work. He's basically volunteering as a co-maintainer. The llama-coder creator (2K stars) clearly believes in Happy.
3. **Leeroybrun is your community champion.** Built the entire self-hosted ecosystem, 40 comments, 5 merged PRs. If you ever want to recognize a community contributor, it's him.
4. **The security audit from brtkwr was a gift.** 7 vulnerabilities found in one day. This is the kind of thing companies pay $50K+ for.
5. **Retention is low (5.6%)** but the retained users are incredibly high-quality. The 28 returning contributors are the product's backbone.
6. **Chinese developer community is massive and growing.** ~35% of contributors. Beijing (Beihang University), Wuhan, HK. i18n and Windows support are high-priority for this segment.
7. **tiann (weishu) is both an ally and a competitor.** His hapi project (3.4K stars) does the same thing. His early Android fixes for Happy were likely cross-pollination. Worth watching.
8. **The "burst contributor" pattern** (12 PRs in 1 day, 18 issues in 1 day) suggests people discover Happy and immediately deep-dive. First impressions matter enormously.
9. **Session management is the #1 pain point** (158 contributions). Daemon lifecycle, tmux, session state — this is where the product needs the most polish.
10. **The product has genuine "vibe coding" PMF.** The fact that Claude Code, Codex, Gemini, Kimi, and OpenCode integrations are all being contributed by users suggests Happy is becoming the universal mobile frontend for AI coding agents.
+80
View File
@@ -0,0 +1,80 @@
# Deployment
This document describes how to deploy the Happy backend (`packages/happy-server`) and the infrastructure it expects.
## Runtime overview
- **App server:** Node.js running `tsx ./sources/main.ts` (Fastify + Socket.IO).
- **Database:** Postgres via Prisma.
- **Cache:** Redis (currently used for connectivity and future expansion).
- **Object storage:** S3-compatible storage for user-uploaded assets (MinIO works).
- **Metrics:** Optional Prometheus `/metrics` server on a separate port.
## Required services
1. **Postgres**
- Required for all persisted data.
- Configure via `DATABASE_URL`.
2. **Redis**
- Required by startup (`redis.ping()` is called).
- Configure via `REDIS_URL`.
- Managed by this repo: `packages/happy-server/deploy/happy-redis.yaml` (StatefulSet + redis-exporter sidecar).
3. **S3-compatible storage**
- Used for avatars and other uploaded assets.
- Configure via `S3_HOST`, `S3_PORT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET`, `S3_PUBLIC_URL`, `S3_USE_SSL`.
- **Deployed separately** — not managed by this repo's Kubernetes manifests. In prod, the S3-compatible service (MinIO or similar) behind `S3_PUBLIC_URL` is provisioned and managed by external infrastructure. The app only consumes it via env vars: `S3_PUBLIC_URL` is set in the Deployment, and credentials come from Vault via ExternalSecret (`/handy-files`).
- If `S3_HOST` is unset, the server falls back to local filesystem storage (`./data/files/`).
- For local k8s dev, a MinIO pod is deployed via `deploy/overlays/local/minio.yaml`.
## Environment variables
**Required**
- `DATABASE_URL`: Postgres connection string.
- `HANDY_MASTER_SECRET`: master key for auth tokens and server-side encryption.
- `REDIS_URL`: Redis connection string.
- `S3_HOST`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET`, `S3_PUBLIC_URL`: object storage config.
**Common**
- `PORT`: API server port (default `3005`).
- `METRICS_ENABLED`: set to `false` to disable metrics server.
- `METRICS_PORT`: metrics server port (default `9090`).
- `S3_PORT`: optional S3 port.
- `S3_USE_SSL`: `true`/`false` (default `true`).
**Optional integrations**
- GitHub OAuth/App: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GITHUB_APP_ID`, `GITHUB_PRIVATE_KEY`, `GITHUB_WEBHOOK_SECRET`, plus redirect URL/URI.
- `GITHUB_REDIRECT_URL` is used by the OAuth callback handler.
- `GITHUB_REDIRECT_URI` is used by the GitHub App initializer.
- Voice: `ELEVENLABS_API_KEY` (required for `/v1/voice/conversations` in production).
- Subscriptions: `REVENUECAT_API_KEY` (server-side RevenueCat key, required for voice subscription checks).
- Debug logging: `DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING` (enables file logging + dev log endpoint).
## Docker image
A production Dockerfile is provided at `Dockerfile.server`.
Key notes:
- The server defaults to port `3005` (set `PORT` explicitly in container environments).
- The image includes FFmpeg and Python for media processing.
## Kubernetes manifests
Example manifests live in `packages/happy-server/deploy`:
- `handy.yaml`: Deployment + Service + ExternalSecrets for the server.
- `happy-redis.yaml`: Redis StatefulSet + Service + ConfigMap.
The deployment config expects:
- Prometheus scraping annotations on port `9090`.
- A secret named `handy-secrets` populated by ExternalSecrets.
- A service mapping port `3000` to container port `3005`.
## Local dev helpers
The server package includes scripts for local infrastructure:
- `pnpm --filter happy-server db` (Postgres in Docker)
- `pnpm --filter happy-server redis`
- `pnpm --filter happy-server s3` + `s3:init`
Use `.env`/`.env.dev` to load local settings when running `pnpm --filter happy-server dev`.
## Implementation references
- Entrypoint: `packages/happy-server/sources/main.ts`
- Dockerfile: `Dockerfile.server`
- Kubernetes manifests: `packages/happy-server/deploy`
- Env usage: `packages/happy-server/sources` (`rg -n "process.env"`)
+80
View File
@@ -0,0 +1,80 @@
# Dev Environments
This document covers the local environment manager in [`environments/environments.ts`](../environments/environments.ts).
## What `pnpm env:*` Does
- `pnpm env:new`: create a new isolated environment under `environments/data/envs/<name>`.
- `pnpm env:use <name>`: switch the current environment.
- `pnpm env:server`: run the server inside the current environment.
- `pnpm env:web`: run the web app inside the current environment.
- `pnpm env:cli`: run the CLI inside the current environment.
Each environment injects its own:
- `HAPPY_HOME_DIR`
- `HAPPY_SERVER_URL`
- `HAPPY_WEBAPP_URL`
- `HAPPY_PROJECT_DIR`
- Expo/server port settings
- dev auth values when seeded
Each fresh environment also gets a copied lightweight fixture project from
`environments/lab-rat-todo-project/` at `environments/data/envs/<name>/project`.
Current limitation: the lab-rat project is copied as plain files only. It does
not include git history yet, so provider tests that depend on realistic repo
history still need a later fixture upgrade.
## `pnpm env:cli` Is A Passthrough
`pnpm env:cli` forwards extra arguments directly to `happy`.
Examples:
```bash
pnpm env:cli --help
pnpm env:cli codex
pnpm env:cli daemon status
pnpm env:cli daemon stop
pnpm env:cli daemon start
```
This is equivalent to sourcing the environment and running the CLI manually:
```bash
source environments/data/envs/<name>/env.sh
happy daemon status
```
## Why `env:cli` Exists
It is a convenience wrapper for the current environment. It does not create or pick an environment on its own. It just:
1. Reads `environments/data/current.json`
2. Builds env vars for that environment
3. Launches the CLI with those vars applied
If you want a lower-level, shell-native workflow, use the generated env file directly:
```bash
source environments/data/envs/<name>/env.sh
happy
```
## Restarting The Current Environment Daemon
Either of these now works:
```bash
pnpm env:cli daemon stop
pnpm env:cli daemon start
```
Or:
```bash
source environments/data/envs/<name>/env.sh
happy daemon stop
happy daemon start
```
+549
View File
@@ -0,0 +1,549 @@
# Encryption and Data Encoding
This document details how client data is encrypted, how encrypted blobs are structured, and how those blobs map onto protocol fields. It is based on `packages/happy-cli/src/api/encryption.ts` and the server routes that accept/emit these values.
For transport and event shapes, see `protocol.md`. For HTTP endpoints, see `api.md`.
## Overview
```mermaid
graph TB
subgraph "Client (CLI/Mobile)"
Plain[Plaintext Data]
ClientEnc[Client Encryption]
B64[Base64 Encoded]
end
subgraph "Transport"
Wire[HTTP / WebSocket]
end
subgraph "Server"
Store[(Postgres)]
ServerEnc[Server Encryption]
Tokens[Service Tokens]
end
Plain --> ClientEnc --> B64 --> Wire --> Store
Tokens --> ServerEnc --> Store
style Plain fill:#e8f5e9
style B64 fill:#fff3e0
style Store fill:#e3f2fd
```
## Design goals
- Keep the server blind to user content (end-to-end encryption on clients).
- Use explicit, stable binary layouts so clients can interoperate across versions.
- Prefer simple, consistent base64 encoding on the wire.
## Encryption variants
```mermaid
graph LR
subgraph "Variant Selection"
Check{Has dataKey?}
Check --> |No| Legacy[Legacy NaCl]
Check --> |Yes| DataKey[DataKey AES-GCM]
end
subgraph "Legacy"
L1[XSalsa20-Poly1305]
L2[32-byte shared secret]
end
subgraph "DataKey"
D1[AES-256-GCM]
D2[Per-session/machine key]
end
Legacy --> L1 & L2
DataKey --> D1 & D2
```
Clients currently use one of two encryption variants:
### 1) legacy (NaCl secretbox)
Used when the client only has a shared secret key.
**Algorithm**: `tweetnacl.secretbox` (XSalsa20-Poly1305)
- **Nonce length**: 24 bytes
- **Key length**: 32 bytes
**Binary layout** (plaintext JSON -> bytes):
```
[ nonce (24) | ciphertext+auth (secretbox output) ]
```
```mermaid
packet-beta
0-23: "nonce (24 bytes)"
24-55: "ciphertext + auth tag"
```
### 2) dataKey (AES-256-GCM)
Used when the client supports per-session/per-machine data keys.
**Algorithm**: AES-256-GCM
- **Nonce length**: 12 bytes
- **Auth tag**: 16 bytes
- **Key length**: 32 bytes
**Binary layout**:
```
[ version (1) | nonce (12) | ciphertext (...) | authTag (16) ]
```
```mermaid
packet-beta
0-0: "ver"
1-12: "nonce (12 bytes)"
13-44: "ciphertext (...)"
45-60: "authTag (16 bytes)"
```
- `version` is currently `0`.
## Data encryption key (dataKey variant)
```mermaid
flowchart LR
subgraph "Key Wrapping"
DEK[Data Encryption Key]
Eph[Ephemeral Keypair]
Box[tweetnacl.box]
Bundle[Key Bundle]
end
DEK --> Box
Eph --> Box
Box --> Bundle
subgraph "Content Encryption"
Plain[Plaintext]
AES[AES-256-GCM]
Cipher[Ciphertext]
end
DEK --> AES
Plain --> AES --> Cipher
```
When `dataKey` is used, the actual content key is encrypted for storage/transport.
**Algorithm**: `tweetnacl.box` with an ephemeral keypair.
- **Ephemeral public key**: 32 bytes
- **Nonce**: 24 bytes
**Binary layout**:
```
[ ephPublicKey (32) | nonce (24) | ciphertext (...) ]
```
```mermaid
packet-beta
0-31: "ephPublicKey (32 bytes)"
32-55: "nonce (24 bytes)"
56-87: "ciphertext (...)"
```
This blob is then wrapped with a version byte before being sent/stored:
```
[ version (1 = 0) | boxBundle (...) ]
```
The resulting bytes are base64-encoded and placed in fields such as `dataEncryptionKey` for sessions/machines/artifacts.
## Where encryption is applied
```mermaid
graph TB
subgraph "Client-Encrypted Fields"
direction TB
S1[Session metadata]
S2[Session agent state]
S3[Session messages]
M1[Machine metadata]
M2[Daemon state]
A1[Artifact header]
A2[Artifact body]
K1[KV store values]
AK[Access keys]
end
subgraph "Server Storage"
DB[(Postgres)]
end
S1 & S2 & S3 --> |opaque strings| DB
M1 & M2 --> |opaque strings| DB
A1 & A2 --> |opaque bytes| DB
K1 --> |opaque bytes| DB
AK --> |opaque string| DB
style S1 fill:#e1f5fe
style S2 fill:#e1f5fe
style S3 fill:#e1f5fe
style M1 fill:#e1f5fe
style M2 fill:#e1f5fe
style A1 fill:#e1f5fe
style A2 fill:#e1f5fe
style K1 fill:#e1f5fe
style AK fill:#e1f5fe
```
The server treats these fields as opaque strings/blobs. The client encrypts them before sending.
### Session metadata + agent state
- **Encrypted by client** and stored as strings in the DB.
- Used in:
- `POST /v1/sessions` (create/load)
- WebSocket `update-metadata` / `update-state`
- `update-session` events
### Session messages
```mermaid
sequenceDiagram
participant Client
participant Server
participant DB as Postgres
Client->>Client: Encrypt message
Client->>Server: emit "message" { sid, message: "<base64>" }
Server->>DB: Store { t: "encrypted", c: "<base64>" }
Note over Server: Later, sync to other clients
Server->>Client: update "new-message"<br/>content: { t: "encrypted", c: "<base64>" }
Client->>Client: Decrypt message
```
- Client emits `message` with a base64 encrypted blob.
- Server stores it as `SessionMessage.content`:
- `{ t: "encrypted", c: "<base64>" }`
- Server emits it back in `new-message` updates with the same structure.
### Machine metadata + daemon state
- **Encrypted by client** and stored as strings in the DB.
- Used in:
- `POST /v1/machines`
- WebSocket `machine-update-metadata` / `machine-update-state`
- `update-machine` events
### Artifacts
- `header` and `body` are encrypted bytes encoded as base64 on the wire.
- Stored as `Bytes` in the DB.
- Emitted in `new-artifact` / `update-artifact` events as base64 strings.
### Access keys
- `AccessKey.data` is treated as an **opaque encrypted string**.
- The server does not decode it or inspect its contents.
### Key-value store
- `UserKVStore.value` is encrypted bytes encoded as base64 on the wire.
- `kvMutate` expects base64 strings; `kvGet/list/bulk` return base64 strings.
## On-wire formats (encrypted fields)
```mermaid
graph LR
subgraph "Wire Format"
JSON[JSON payload]
B64["base64 strings<br/>(encrypted bytes)"]
Plain["plain values<br/>(ids, versions, timestamps)"]
end
JSON --> B64
JSON --> Plain
```
Below are the typical JSON shapes that carry encrypted data. All `...` values are base64 strings representing encrypted bytes.
### Session creation
```http
POST /v1/sessions
```
```json
{
"tag": "<string>",
"metadata": "<base64 encrypted>",
"agentState": "<base64 encrypted or null>",
"dataEncryptionKey": "<base64 data key bundle or null>"
}
```
### Encrypted message (client -> server)
```
Socket emit: "message"
```
```json
{
"sid": "<session id>",
"message": "<base64 encrypted>"
}
```
### Encrypted message (server -> client)
```
update.body.t = "new-message"
```
```json
{
"t": "encrypted",
"c": "<base64 encrypted>"
}
```
### Session metadata update (WebSocket)
```
Socket emit: "update-metadata"
```
```json
{
"sid": "<session id>",
"metadata": "<base64 encrypted>",
"expectedVersion": 3
}
```
### Machine update (WebSocket)
```
Socket emit: "machine-update-state"
```
```json
{
"machineId": "<machine id>",
"daemonState": "<base64 encrypted>",
"expectedVersion": 2
}
```
### Artifact create/update (HTTP)
```http
POST /v1/artifacts
```
```json
{
"id": "<uuid>",
"header": "<base64 encrypted>",
"body": "<base64 encrypted>",
"dataEncryptionKey": "<base64 data key bundle>"
}
```
### KV mutate (HTTP)
```http
POST /v1/kv
```
```json
{
"mutations": [
{ "key": "prefs.theme", "value": "<base64 encrypted>", "version": 2 },
{ "key": "prefs.legacy", "value": null, "version": 5 }
]
}
```
## Client-side types (shapes used before encryption)
These are the client-side structures that get encrypted and sent over the wire. They are defined in `packages/happy-cli/src/api/types.ts`.
### Session message content (encrypted)
The payload stored in `SessionMessage.content` is always encrypted and wrapped as:
```json
{ "t": "encrypted", "c": "<base64 encrypted>" }
```
### Encrypted message payload (plaintext before encryption)
Messages are encrypted as `MessageContent` and then base64 encoded:
**User message**
```json
{
"role": "user",
"content": { "type": "text", "text": "..." },
"localKey": "...",
"meta": { }
}
```
**Agent message**
```json
{
"role": "agent",
"content": { "type": "output | codex | acp | event", "data": "..." },
"meta": { }
}
```
### Metadata (encrypted)
```json
{
"path": "...",
"host": "...",
"homeDir": "...",
"happyHomeDir": "...",
"happyLibDir": "...",
"happyToolsDir": "...",
"version": "...",
"name": "...",
"os": "...",
"summary": { "text": "...", "updatedAt": 123 },
"machineId": "...",
"claudeSessionId": "...",
"tools": ["..."],
"slashCommands": ["..."],
"startedFromDaemon": true,
"hostPid": 12345,
"startedBy": "daemon | terminal",
"lifecycleState": "running | archiveRequested | archived",
"lifecycleStateSince": 123,
"archivedBy": "...",
"archiveReason": "...",
"flavor": "..."
}
```
### Agent state (encrypted)
```json
{
"controlledByUser": true,
"requests": {
"<id>": { "tool": "...", "arguments": {}, "createdAt": 123 }
},
"completedRequests": {
"<id>": {
"tool": "...",
"arguments": {},
"createdAt": 123,
"completedAt": 123,
"status": "canceled | denied | approved",
"reason": "...",
"mode": "default | acceptEdits | bypassPermissions | plan | read-only | safe-yolo | yolo",
"decision": "approved | approved_for_session | denied | abort",
"allowTools": ["..."]
}
}
}
```
### Machine metadata (encrypted)
```json
{
"host": "...",
"platform": "...",
"happyCliVersion": "...",
"homeDir": "...",
"happyHomeDir": "...",
"happyLibDir": "..."
}
```
### Daemon state (encrypted)
```json
{
"status": "running | shutting-down",
"pid": 123,
"httpPort": 123,
"startedAt": 123,
"shutdownRequestedAt": 123,
"shutdownSource": "mobile-app | cli | os-signal | unknown"
}
```
## Decryption flow (client side)
```mermaid
flowchart TD
Start([Receive encrypted field]) --> B64[Decode base64 to bytes]
B64 --> Check{Has dataKey?}
Check --> |No| Legacy[Use legacy variant]
Check --> |Yes| DataKey[Use dataKey variant]
subgraph "Legacy Path"
Legacy --> ExtractL[Extract nonce + ciphertext]
ExtractL --> DecryptL[secretbox.open with shared key]
end
subgraph "DataKey Path"
DataKey --> GetDEK[Decrypt dataEncryptionKey bundle]
GetDEK --> ExtractD[Extract version + nonce + ciphertext + tag]
ExtractD --> DecryptD[AES-GCM decrypt with DEK]
end
DecryptL --> Plain([Plaintext JSON])
DecryptD --> Plain
```
- Read base64 field from API/Socket.
- Decode base64 to bytes.
- Choose encryption variant (`legacy` or `dataKey`) based on local credentials.
- Decrypt bytes using the appropriate key and algorithm.
For `dataKey`, clients must first decrypt or derive the per-session/per-machine data key from the stored `dataEncryptionKey` bundle.
## Server-side encryption (service tokens)
```mermaid
graph LR
subgraph "Third-Party Tokens"
GH[GitHub OAuth]
OAI[OpenAI]
ANT[Anthropic]
GEM[Gemini]
end
subgraph "Server"
Secret[HANDY_MASTER_SECRET]
KeyTree[KeyTree]
Encrypt[Encrypt]
end
DB[(Postgres)]
Secret --> KeyTree --> Encrypt
GH & OAI & ANT & GEM --> Encrypt --> DB
style GH fill:#fff3e0
style OAI fill:#fff3e0
style ANT fill:#fff3e0
style GEM fill:#fff3e0
```
The server encrypts certain third-party tokens at rest:
- GitHub OAuth tokens (`GithubUser.token`).
- Vendor service tokens (`ServiceAccountToken.token`).
These are encrypted with a server-only KeyTree derived from `HANDY_MASTER_SECRET` and are not end-to-end encrypted.
## Encoding conventions
```mermaid
graph TB
subgraph "Encoding Rules"
E1["Encrypted bytes → base64 string"]
E2["Timestamps → plain number (epoch ms)"]
E3["IDs, tags, versions → plain string/number"]
end
subgraph "Examples"
Ex1["metadata: 'SGVsbG8gV29ybGQ='"]
Ex2["createdAt: 1704067200000"]
Ex3["id: 'abc-123', version: 5"]
end
E1 --> Ex1
E2 --> Ex2
E3 --> Ex3
```
- All encrypted bytes are base64 strings on the wire unless explicitly noted.
- Timestamps remain plain numbers (epoch ms) and are not encrypted by the server.
- Non-encrypted identifiers (ids, tags, versions) are always plain strings/numbers.
## Implementation references
- Client crypto: `packages/happy-cli/src/api/encryption.ts`
- Session message format: `packages/happy-cli/src/api/types.ts`
- Server message ingestion: `packages/happy-server/sources/app/api/socket/sessionUpdateHandler.ts`
- Artifact/KV routes: `packages/happy-server/sources/app/api/routes/artifactsRoutes.ts`, `packages/happy-server/sources/app/kv/kvMutate.ts`
+118
View File
@@ -0,0 +1,118 @@
# Engineer Agent
Purpose: execute a scoped roadmap task inside an assigned worktree and validate
it in an isolated environment owned by that worktree.
## Role
You are the executor. The manager handles dispatch and oversight. You do not
need to coordinate with other engineers directly. Report back through your own
Happy session.
## Planes
Keep these planes separate:
1. Control plane: your Happy session is visible to the manager in the shared
Happy space.
2. Code plane: your assigned git worktree.
3. Validation plane: your worktree-local Happy env started with `yarn env:up`
from that worktree.
Being visible in the shared Happy space does not mean you should test in the
shared manager environment.
## Required Workflow
1. Read the exact task given by the manager. Treat that scoped task as the
source of truth.
2. Work only in the assigned worktree.
3. Audit branch state before new work:
- `git status --short --branch`
- `git rev-parse --abbrev-ref HEAD`
- `git rev-parse HEAD`
- `git rev-list --left-right --count main...HEAD`
4. If the worktree is dirty, create a draft checkpoint commit before rebasing.
5. Rebase onto the local `main` branch before starting or continuing task work.
6. Start your isolated env from that worktree with `yarn env:up`.
7. Build any required local artifacts in your worktree before testing.
8. Validate your changes only in your own isolated env.
9. Capture screenshots at key checkpoints and a final end-to-end video.
10. Report back in this Happy session with exact commands and clear risks.
## Environment Rules
- Do not test in the manager's shared env.
- Do not assume an existing shared daemon/web process proves your changes.
- Do not add fallbacks, backwards-compatibility shims, or parallel legacy
paths unless the scoped task explicitly requires them.
- Do not treat a toy happy-path as sufficient validation when the feature is
meant to survive real usage.
- If you changed CLI code, rebuild the CLI in your worktree before daemon or
CLI validation.
- If you changed app code, verify the running app instance is serving from your
worktree and not from some other worktree or from main.
- If there is a sample or example project relevant to the feature, use it.
Otherwise say exactly what project, fixture, or scenario you used.
## QA Standard
The manager is specifically looking for realistic validation, not shallow proof.
- Exercise the feature under conditions closer to real Happy usage: longer
chats, multiple steps, navigation, reload/resume, multiple artifacts, and
realistic project state where relevant.
- Fully exercise the feature, not just the first obvious success path.
- Capture screenshots at the key state transitions the manager will want to
inspect precisely.
- Record a final end-to-end video artifact that shows the feature working.
- If available, run `gemini` CLI on screenshots and video, or `claude` CLI on
screenshots, as an extra review pass. Report the exact commands you used.
## UI Variant Policy
When the task is UI-facing and the manager asks for design options:
- Provide five competing implementation options.
- If the differences are mostly presentational, make switching or comparing
them easy in the same worktree.
- Good lightweight switching mechanisms include a local variant constant,
feature flag, style token map, or a small component boundary.
- If your assigned worktree is a variant-specific sibling worktree, stay within
that option and do not blur it back into the others.
- Be explicit about what is shared across options and what is materially
different.
## Communication Rules
- Report only through your own Happy session.
- Do not rely on other engineers to explain your state.
- Be skeptical. Say exactly what remains untested.
## Minimum Report
Every final reply must include:
- outcome: done|partial|blocked
- worktree: absolute path
- branch: branch name
- head_sha: commit sha after rebase/testing
- env_name: isolated env name
- what_changed: concise summary
- how_tested: exact commands and product checks
- verification_url: URL for the manager to inspect, or `none`
- screenshots: file paths or URLs, or `none`
- video: file path or URL, or `none`
- remaining_risks: concise honest statement
## Failure Rules
- If you could not start an isolated env, say so clearly and stop claiming full
validation.
- If you only typechecked or only built, say `partial`, not `done`.
- If you validated in the wrong env, say so explicitly and treat validation as
incomplete.
- If you skipped the required rebase onto local `main`, say so explicitly and
treat the task as incomplete.
- If you only validated a toy path or did not capture the required evidence,
say `partial`, not `done`.
+178
View File
@@ -0,0 +1,178 @@
# Manager Agent
Purpose: operate the control plane for delegated work.
The manager does not need engineers to talk to each other. Each engineer has a
Happy session. The manager inspects and steers that session with `happy-agent`.
## Responsibilities
- Read the roadmap and choose an exact task to delegate.
- Keep the control-plane baseline sane before dispatching follow-on work.
- Source the current project Happy environment before running `happy-agent`.
- Spawn engineer sessions with `happy-agent`.
- Point each spawned session at `.agents/agents/engineer.md`.
- Give the engineer the exact roadmap item or exact scoped excerpt.
- Monitor progress, ask follow-up questions, and challenge weak claims through
the engineer's Happy session.
- Collect skeptical test evidence before considering a task complete.
## Planes
There are three separate planes. Do not collapse them.
1. Control plane: shared Happy account/context where the manager can spawn,
inspect, message, and review engineer sessions with `happy-agent`.
2. Code plane: the engineer's assigned git worktree where code changes happen.
3. Validation plane: the engineer's worktree-local Happy environment created
from that worktree with `yarn env:up`.
Shared visibility does not mean shared runtime-under-test.
## Dispatch Rules
- Spawn one engineer per task or tightly related task bundle.
- Use a dedicated worktree for each engineer task.
- For a new task, create a fresh worktree from a clean local `main` baseline.
- `happy-agent` is orchestrator-only. Engineers do not need to know about it or
use it.
- Do not ask the engineer to validate in the manager's current shared env.
- Before any new work, require the engineer to audit branch state in their own
worktree.
- If the worktree is dirty, require a draft checkpoint commit before rebasing.
- Require the engineer to rebase onto the local `main` branch before starting
or continuing task work.
- Require the engineer to run `yarn env:up` inside their own worktree before
claiming product validation, and again after any rebase that changes code.
- Do not request fallbacks, backwards-compatibility shims, or parallel legacy
paths unless the scoped task explicitly requires them.
- Treat the roadmap as product scope only. Do not store agent workflow there.
If `main` is dirty or otherwise not ready to serve as the rebase target, fix
that first. Do not tell engineers to rebase onto a stale or ambiguous control
plane.
## Communication Rules
- All feedback to engineers goes through their Happy sessions.
- Do not rely on side channels between engineers.
- Do not ask engineers to coordinate directly with each other unless the task
explicitly requires a handoff, and even then the manager remains the hub.
- Keep polling active engineer sessions. Push immediately when a claim is vague,
weakly tested, or unsupported.
## QA Standard
The key failure mode is shallow validation.
- Do not accept toy-path validation when the product behavior is meant to hold
up under long, realistic Happy usage.
- Ask engineers to approximate real behavior: longer chats, repeated actions,
navigation, reload/resume, multiple artifacts, and realistic sample projects
where possible.
- If there is an example or canonical project in the repo, have the engineer
use it. If not, require them to say exactly what project or fixture they used.
- Screenshots are required at key checkpoints for precise inspection.
- End-to-end video is required for the final presentation artifact.
- If available, require an extra validation pass using `gemini` CLI for
screenshots and video, or `claude` CLI for screenshots. Those tools do not
replace direct manager review; they are extra scrutiny.
## UI Variant Policy
For UI-facing work, do not default to a single presentation option.
- Ask for five competing implementation options for each UI feature.
- If multiple options can share the same structure, require the engineer to
make switching between them easy.
- Prefer lightweight switches such as a local variant constant, feature flag,
style token set, or small component boundary when the differences are mostly
presentational.
- If an option requires a meaningfully different layout, data flow, or
interaction model, split it into a separate engineer task with its own
dedicated worktree.
- Use sibling worktree names for those parallel variants, for example
`p3-session-tool-ui-a`, `p3-session-tool-ui-b`, and so on.
- Require each UI option report to explain what is shared, what differs, and
how easy it is to switch or compare the variants.
## Required Spawn Payload
Every engineer spawn message should include:
- the instruction to follow `.agents/agents/engineer.md`
- the exact roadmap item or scoped excerpt
- the assigned worktree name/path
- the requirement to audit `git status`, checkpoint if dirty, and rebase onto
local `main` before new work
- the requirement to run `yarn env:up` in that worktree
- the requirement to test only in that isolated env
- the requirement to test realistically, not just on a toy happy-path
- the requirement to capture screenshots at key checkpoints and a final video
- for UI tasks, the requirement to provide five competing options and make
switching easy when feasible
- the requirement to report exact commands, env name, ports, verification URL,
and remaining risks
## Spawn Template
Use this shape when sending the initial task:
```text
Follow /Users/kirilldubovitskiy/projects/happy/.agents/agents/engineer.md.
Task source of truth:
<exact roadmap item or exact scoped excerpt>
Execution constraints:
- Work only in the assigned worktree: <worktree path>
- Before new work, run `git status --short --branch`
- If dirty, create a draft checkpoint commit
- Rebase onto local `main`
- Start an isolated env from that worktree with `yarn env:up`
- Test only against that worktree-local env
- Do not validate against the shared manager env
- Use a realistic sample project or scenario and say what you used
- Capture screenshots at key checkpoints and a final end-to-end video
- For UI tasks, provide five competing implementation options; if the options
are lightweight variants, make switching/comparison easy, and if they are
materially different, say so clearly so the manager can split them into
sibling worktrees
- If available, use `gemini` CLI for video/screenshot review or `claude` CLI
for screenshot review, and report the exact commands
- Report back only through this Happy session
- Be explicit about what you did not test
Required final report:
- outcome: done|partial|blocked
- worktree: <path>
- branch: <branch>
- head_sha: <sha>
- env_name: <name>
- what_changed: <one line>
- how_tested: <exact commands>
- verification_url: <url or none>
- screenshots: <paths or none>
- video: <path/url or none>
- remaining_risks: <one line>
```
## Review Standard
Do not accept "done" without:
- branch audit output or an exact summary of it
- proof the engineer rebased onto local `main`
- exact commands
- isolated env name
- proof the engineer tested in their own worktree env
- proof that the validation was realistic enough to stress the feature
- screenshots at key checkpoints
- a final video artifact
- concrete remaining risks, or an explicit statement that none remain
If the engineer tested in the shared env instead of their own isolated env, the
task is not accepted.
If the engineer only validated a toy path, skipped realistic load/flow
coverage, or provided only screenshots without video, the task is not accepted.
+5
View File
@@ -0,0 +1,5 @@
# Agents (Experimental)
The `agents-engineer.md` and `agents-manager.md` files were agent definitions for an automated software factory experiment — the idea was to have AI agents autonomously build and test Happy.
It hasn't sped up development yet. Will revisit later. Moved here to avoid confusion with the main project.
+183
View File
@@ -0,0 +1,183 @@
# Manual Product Validation
This is the full Happy product check.
It is manual on purpose.
## Boot A Local Environment
From repo root:
```bash
yarn env:new
```
Start services in separate terminals:
```bash
yarn env:server
yarn env:web
```
Useful helpers:
- `yarn env:list` shows available environments
- `yarn env:current` prints the active environment path
- `yarn env:cli` starts `happy` inside the current environment
For daemon and agent work, use a sourced shell:
```bash
source environments/data/envs/<name>/env.sh
happy daemon start
```
That shell now points at the local stack through:
- `HAPPY_SERVER_URL`
- `HAPPY_WEBAPP_URL`
- `HAPPY_HOME_DIR`
Use the local web app URL printed by `yarn env:web`, or open the mobile app if
you are testing on device.
## External CLI Use
If you are driving the flow from an external CLI such as `agent-browser`, run
it from the same sourced shell:
```bash
source environments/data/envs/<name>/env.sh
agent-browser ...
```
That keeps the browser-side tooling pointed at the current local environment
instead of production.
## Manual Flow
### 1. Verify the machine is visible
- open the app
- sign in
- confirm the machine appears
- confirm daemon status is healthy
Expected:
- machine is listed
- machine can be opened
- app does not show offline or spawn errors
### 2. Spawn a session
- open the machine screen
- pick an agent
- choose a directory
- spawn the session
Expected:
- session appears in active sessions
- session shows the selected agent
- session enters a running or ready state
### 3. Send a basic prompt
Send:
`Reply with exactly: happy-e2e-ok`
Expected:
- response streams into the session
- final answer is visible in the app
- session returns to idle or ready
### 4. Verify context across turns
Send:
`The secret token is blue-falcon-42. Reply with the token only.`
Then send:
`What was the secret token from my previous message?`
Expected:
- the second response still knows `blue-falcon-42`
### 5. Verify permissions
Send a prompt that forces a tool call or file write.
Run it three ways:
1. approve
2. deny
3. cancel
Expected:
- approve completes
- deny is handled cleanly
- cancel does not hang the session
### 6. Verify model switching
If the agent supports model switching:
- switch model from the app
- send another message that depends on earlier context
Expected:
- the model change takes effect
- prior context is preserved
### 7. Verify interruption
Send a prompt that runs long enough to interrupt.
- interrupt while the agent is thinking
- interrupt during a tool call if possible
Expected:
- the current turn stops cleanly
- the app reflects the interruption
- the next prompt still works
### 8. Verify history
- leave the live session screen
- reopen the session
- inspect history
Expected:
- prompts and outputs are present
- permission outcomes are reflected correctly
- interrupted turns do not leave the session broken
### 9. Verify stop
- stop the session from the app
Expected:
- the session leaves the active state
- no zombie process remains
## Minimum Agents
Run this flow for:
- Codex
- Claude
- Gemini
- OpenClaw
If one is blocked by provider auth or environment issues, record the block
explicitly.
+413
View File
@@ -0,0 +1,413 @@
# Roadmap
This file is the cross-product execution plan for the current Happy push.
# Key Milestones
- wrap up current improvements NO NEW SCOPE - focus on stabilizing features, not new features
- release beta / test on main
- start charging for voice - find the branch somewhere / figure out how to test this exactly on prod build?
- How to configure
- ship new app build
- share talk to
## Working rules
- Agent workflow is defined in `.agents/agents/manager.md` and
`.agents/agents/engineer.md`. The roadmap is product scope, not the source of
truth for orchestration behavior.
- Web is the primary validation surface for now. Full validation still includes
the real server and real CLI behavior, but manual product testing should be
done on web before spending time on iOS.
- Keyboard shortcuts are deprioritized.
- Do not change individual chat ordering. If ordering work is done, it should apply to worktree or project groups, not to individual sessions.
- Right-click archive already exists and should be preserved.
- "Background separation like conductor" is not a standalone requirement unless it naturally falls out of simplifying the layout.
- Use Expo best practices for both native and web, even when web is the only surface being manually validated.
## P0. Happy-agent orchestration and task fan-out
Goal: make `happy-agent` the reliable control plane for dispatching and monitoring the rest of this roadmap.
### Required outcomes
- Verify the current `happy-agent` implementation on the real stack from this current environment before using it to spawn work for the rest of the roadmap.
- Fix any blocking issues in the current branch first, rather than assuming `happy-agent` is ready and immediately branching into many worktrees.
- Ensure that a spawned agent session appears in the same authenticated Happy environment as the current session, so the user can see those chats later without switching accounts or contexts.
- Use `happy-agent` to create worktrees and spawn new agent sessions only after the base flow is proven locally.
- After the base flow is stable, scale to parallel task fan-out, with a target of roughly 10 concurrent agents only if monitoring and reporting are already reliable.
### Concrete requirements
- Finish and validate `happy-agent spawn`, mirroring the app's `spawn-happy-session` flow.
- Spawn must create or choose a worktree for the task rather than reusing the current working tree.
- Spawned session metadata must clearly retain:
- machine
- project path
- worktree path
- agent flavor
- session id
- thread id or equivalent provider metadata when available
- Test the current auth path and ensure the agent runs under the same Happy account/environment as the current session.
- If different privilege models are needed, support that explicitly instead of hiding it. The likely split is:
- same-account control for normal spawned agents
- elevated flow only where strictly necessary
- Add a monitoring flow that can continuously check status across many spawned sessions and report:
- active vs idle
- pending permission/tool requests
- last meaningful output
- whether real validation evidence has been attached
- Add a reporting flow that writes status back into this roadmap under each task instead of leaving results scattered across chat history.
- Do not trust a spawned agent's "done" message by default. Require it to provide:
- exact scope completed
- concrete tests performed
- a web URL the user can open
- any caveats, skipped items, or uncertainty
- Support the longer-term workflow ideas, but only after the base flow is solid:
- per-agent install/setup instructions
- post-agent hooks
- spawning a defined follow-up agent after a session
- project-level or session-level automatic follow-up agents
- simple "omni agent" / conductor-like checks stack
### Validation requirements
- Validate on web with the real server and real CLI, not a mocked environment.
- Prove the flow in the current environment first:
1. authenticate or reuse existing auth in the current env
2. spawn a real agent into a new worktree
3. confirm the session is visible in the same Happy environment
4. send work to it
5. monitor it to idle
6. collect a real verification link
7. write the report back into this roadmap
- Only after this passes should the other roadmap items be delegated through `happy-agent`.
## P1. Control-flow, permissions, and protocol bugs
Goal: remove the broken session-control paths that currently make remote agent management unreliable.
### Required outcomes
- Fix Claude permission flows that are still broken.
- Fix Codex permission and sandbox flows that still block useful work outside `yolo`.
- Fix missing approval UI when a plan is proposed.
- Fix task/tool rendering failures that hide agent output.
- Fix missing or unclear session/thread/provider metadata where it blocks orchestration or debugging.
### Concrete requirements
- Fix "Yes, don't ask again" / session-scoped approval behavior for Claude Code permissions.
- Fix Claude plan proposals that do not show approve / deny buttons.
- Repro session:
- worktree: `~/projects/happy/happy/.dev/worktree/wise-river`
- Happy session id: `cmmbujpkq03iey7lcxyd9fqaw`
- Fix Codex sandbox behavior where work is still blocked in non-`yolo` modes when it should be allowed by the selected permission mode.
- Fix Codex session stopping — currently unreliable / painful.
- Fix Codex sessions appearing stuck in "thinking" indefinitely with no updates — may be a frontend rendering issue where updates aren't being pushed to the session view.
- Fix task rendering for tool calls like:
- `TaskOutput`
- `TaskStop`
- Fix multi-file and regular edit rendering/resolution so file diffs and file targets resolve correctly instead of producing broken or misleading output.
- Ensure permission UI correctly handles and persists the real decision that was made:
- approve
- deny
- approve for session
- allow all edits
- abort / stop and explain
- Ensure permission state is not duplicated, dropped, or shown with the wrong buttons for Claude vs Codex.
- Ensure provider/session metadata needed for orchestration is stored clearly enough to inspect and debug:
- Happy session id
- provider session/thread id when available
- flavor / agent type
- machine / path / worktree context
### Session protocol: message consumption visibility
- For all agents (not just Codex): no way to know if a message was actually consumed by the agent
- Need read receipts / consumption acknowledgment at the protocol level
- Secondary: per-agent integration quirks are a separate swimlane (#agent-integrations)
### Validation requirements
- Reproduce and verify fixes on web with real sessions.
- For permission fixes, verify both the UI path and the resulting agent behavior after the decision is sent.
- For plan approval fixes, verify approve and deny both work.
- For task rendering fixes, verify the output is actually visible and meaningful in the session transcript.
## P2. Composer overhaul
Goal: make new-session composition feel like the regular chat composer instead of a separate, more awkward surface.
### Required outcomes
- The new-session composer should be visually and behaviorally close to the regular chat input.
- The input should become the main focus of the layout, especially on laptop/web.
- The composer must support the missing path and attachment workflows needed for real use.
### Concrete requirements
- Keep the new-thread flow inline. Do not reintroduce a separate detached "new chat" surface.
- Keep the project picker on empty/new thread only.
- For an active chat, keep the regular chat input shape and only surface the relevant controls there, primarily model and permissions.
- Support entering a custom path directly instead of forcing only picker-based selection.
- Add image support.
- Add a `+` entry point at the lower left for attachments, and wire it to the encrypted file handling already supported by the product where possible.
- Reduce the amount of chrome above the input. The desired hierarchy is:
- machine
- project path
- agent
- the main input area
- The project path should be right-aligned in the composer header row.
- When interacting with machine / folder / worktree controls on desktop, auto-focus the relevant search field.
- The main input area should be much closer to the regular chat input, including:
- similar visual weight
- larger, more readable text
- permissions / model / thinking controls integrated into the input row instead of stacked above it
- Worktree behavior in the composer must stay first-class:
- choose no worktree
- choose an existing worktree
- create a new worktree
- Worktrees that match the project's worktree pattern should be treated as part of the same project rather than feeling like unrelated projects.
### Validation requirements
- Validate end-to-end on web.
- Confirm the spawn path still works with real server + CLI integration, not just local component state.
- If drag-and-drop behavior is added later in this area, capture a web video of the interaction.
## P2.5. PI-style agent controls, fork, and resume
Goal: make active-session agent controls feel first-class instead of scattered across info screens and one-off flows. The control surface should feel closer to a PI-style agent UI while still preserving Happy's regular chat input shape.
### Required outcomes
- An active chat should expose the primary agent controls in a way that is fast to scan and fast to use.
- Forking and resuming should feel like normal agent controls, not buried recovery flows.
- The user should always be able to tell what agent/session they are controlling:
- flavor / agent type
- permission mode
- model / effort or thinking level when relevant
- machine / project path / worktree
- provider thread or resume context when available
- The design should borrow from PI-style control surfaces where useful, but should still fit Happy's chat-first product shape.
### Concrete requirements
- Build on the existing active-chat composer direction rather than inventing a separate detached control panel.
- For an active chat, keep the regular chat input shape and surface the relevant agent controls there or immediately adjacent to it.
- Support quick access to:
- model
- permissions
- effort / thinking level where supported
- stop / archive / resume
- fork session
- machine / path / worktree context
- Treat fork/resume as a first-class product flow:
- right-click or quick action to fork an existing session
- show a clear `<resuming session>` or equivalent context pill
- allow choosing a different worktree
- allow choosing a different agent where supported
- use the resume session API on the machine to fork the underlying conversation
- Reuse the current session metadata and quick-action work rather than creating a second disconnected control path.
- Where PI-style controls imply protocol or lifecycle expectations, align with the protocol research already captured for ACP / Pi RPC rather than inventing another opaque control model.
- For UI design exploration, provide five competing implementation options. Keep switching between lightweight variants easy; if a variant is structurally different, split it into a sibling worktree track.
### Validation requirements
- Validate on web with real long-running sessions, not a tiny toy transcript.
- Exercise realistic behavior:
- change controls during an active chat
- fork a real session
- resume or branch it into another worktree
- confirm the new branch/session remains clearly attributable
- Record a web video of the full flow.
- Capture screenshots at key checkpoints:
- before control change
- after control change
- fork/resume composer state
- resulting branched session state
## P3. Session list, tool UI, and worktree-level ordering
Goal: reduce visual bloat, improve scanability, and make high-priority work easier to manage without touching per-chat ordering.
### Required outcomes
- Sessions and tools should be easier to scan on web.
- Worktree/project level prioritization should be possible.
- Archive actions should feel safe and reversible.
### Concrete requirements
- Add archive confirmation. Archiving should feel safe because resuming an existing session is trivial.
- Keep right-click archive and related quick actions available on web.
- Improve subagent presentation so nested work is clearly attributed and grouped.
- Do not show provider tool calls in a way that flattens or hides the subagent structure.
- Reduce tool UI bloat on web:
- remove unnecessary button backgrounds and layering
- make tool action buttons less bulky
- group them more cleanly once the relevant output is done
- Eliminate the duplicated plan presentation where both raw file-edit content and the plan tool are effectively shown twice.
- Fix the black stripe artifact in file edit tool-call rendering.
- Fix markdown image rendering in session/chat messages so absolute-path screenshot syntax like `![](/absolute/path.png)` previews inline on web instead of failing silently during manager review.
- Ensure long worktree paths do not overlap with git changes or other row content.
- Add ordering by importance at the worktree/project level, not the individual chat level.
- When implementing ordering, support dragging worktree/project groups on web first.
### Validation requirements
- Validate all UI changes on web.
- When drag ordering ships, record a web video showing the interaction.
- Confirm that session grouping and archive actions still work after the layout changes.
- Verify that markdown image syntax using local absolute paths renders an actual inline preview in a real web session.
## P4. File links, changed-files review, and attachments
Goal: make file references in chat actually useful and make file review/attachment flows feel complete.
### Required outcomes
- File references in chat should resolve to something real.
- Clicking a file should open an actual file viewer, not just a dead-looking link.
- The changed-files review surface should match the underlying data correctly.
- Composer attachments should work in both new and regular chat flows.
### Concrete requirements
- Before rendering a file path as a clickable link, try to resolve it against the remote machine/session context.
- On click, fetch the file on demand again so the opened file reflects the current remote state.
- Open files in a full-screen file screen/viewer rather than a tiny inline fragment.
- Support file drop / attach in both:
- the new-session composer
- the regular in-chat composer
- Reuse encrypted file transport/storage already supported by the product where possible instead of inventing a second path.
- Fix the changed-files review/input mismatch so the review surface corresponds to the right files and content.
### Validation requirements
- Validate on web against a real remote session.
- Verify both initial resolution and refetch-on-open behavior.
## User Research
Goal: talk to users regularly to understand why they use Happy, what their day-to-day problems are, and what to build next.
### Outreach
- In-app PostHog survey offering your phone number / way to reach you directly
- Make it personal — "text me, I want to hear how it's going"
### Interview process
- When we actually talk, collect consent to record/transcribe
- Take structured notes during each conversation
- Store notes somewhere accessible (TBD — `/research` dir, Notion, or markdown)
### What to learn
- Why they started using Happy
- What their day-to-day workflow looks like
- What's painful or missing
## Growth & Promotion Pipeline
Goal: simple pipeline to promote Happy Coder and maintain the public repo presence.
### Promotion
- Regular posts / content about Happy Coder — what it does, how it works, real usage examples
- Figure out channels (Twitter/X, Reddit, HN, Discord, etc.)
- Collect and share user stories from the research interviews (with consent)
### Repo maintenance
- Keep GitHub issues triaged and organized
- Respond to community issues and PRs
- Use issues as a lightweight public roadmap signal
## Happy Evolve (self-modifying UI)
Goal: make it possible to customize any part of the Happy interface from within Happy itself. The app modifies its own frontend live.
### Approach
- Use Metro hot reloading to apply changes in real time
- Focus on making the frontend fully changeable for now
- No sync needed initially — local-only modifications
- Inspiration: pi.exe agent style self-modification, but more ambitious
### For later
- Pull in sync engine idea from Kirill's Happy fork where the sync engine is factored out
## Dynamic Session Icons
Goal: the brutalist icons are a big part of what makes Happy feel good to use — lean into that.
- Generate custom brutalist-style vector icons per session based on the topic
- Keep the same aesthetic — bold, minimal, appealing
- Potential paid feature
- TBD: generation approach (local model, API, precomputed set, etc.)
## Session Forking
Goal: right-click a session to fork it — clone the session in Happy + use the resume session API to fork the conversation on the machine. Lets you explicitly parallelize and control both branches.
### Flow
- Right-click session → "Fork"
- Opens a fork composer (like the regular composer) with:
- a `<resuming session>` pill showing what you're forking from
- ability to pick a different worktree
- ability to pick a different agent
- all the usual composer controls (model, permissions, path, etc.)
- On submit: clones the session in Happy, calls resume session API on the machine to fork the underlying conversation
## Session Protocol (UNDER REVIEW — FROZEN)
The session protocol (`role: 'session'` envelopes in `happy-wire/src/sessionProtocol.ts`) is **not used in production** and should not be used in dev environments either until we revisit the design. The legacy protocol (`role: 'user'` / `role: 'agent'`) is the active code path everywhere.
### Status
- Types are frozen in `happy-wire` — no new consumers
- Dev env was using it but should stop
- Production has never shipped it
### Before resuming
- Look at how pi.dev standardizes their agent protocol — we may want to align with or build on that instead of rolling our own envelope format
- Consider whether `happy-wire` should even own this, or if protocol definition belongs closer to the CLI / agent layer
- The current design may be over-engineered for what we actually need
## Deferred / later
- Keyboard shortcuts:
- new session
- next session
- Chrons board exploration
- Sample project / devx improvements
- Growth tracks:
- Linear integration
- more agents (`opencode`, `openclaw`, `conductor`)
- Claude Code team of agents
- software factory / `happy-agent`
## Native guardrail when native validation is needed later
- Do not recompile the iOS or Android client for JS-only changes when the development build is already installed and still matches the current native code.
- Prefer starting Metro against the current env and reusing the installed dev client.
- Rebuild with `yarn env:ios` or `yarn env:android` only when the build is missing, outdated, or native dependencies/config changed.
- Native app test flow:
1. Start an authenticated env with `yarn env:up:authenticated` or reuse the current env from `yarn env:current`.
2. Source the env so Expo picks up the right server and dev auth vars: `source environments/data/envs/<env-name>/env.sh`.
3. For JS-only work, start Metro without recompiling native: `APP_ENV=development yarn --cwd packages/happy-app start --dev-client --port 8081`.
4. Open the installed simulator or device build from Metro with `i` or `a`, or reopen the dev client onto the Metro URL.
5. Confirm native auth is correct in Metro logs:
- `credentials ...`
- `📊 Sync: Fetched <n> machines from server`
- `📥 fetchSessions completed - processed <n> sessions`
6. Verify the target flow in-app. For session quick actions:
- long-press a session row in the session list
- long-press the top-right session avatar in a session
- on web, right-click the same surfaces
+117
View File
@@ -0,0 +1,117 @@
# happy-wire
This document describes the shared wire package: `@slopus/happy-wire`.
## Why this package exists
Before `happy-wire`, wire-level message and session-protocol schemas were duplicated across packages (CLI, app, server, and agent). That caused drift risk and made protocol evolution harder.
`@slopus/happy-wire` centralizes those shared schemas and types so all clients and services agree on the same wire contract.
## Package identity
- npm name: `@slopus/happy-wire`
- workspace path: `packages/happy-wire`
- package type: publishable library (not private)
- versioned dependency in consumers: `^0.1.0`
## What is shared
### 1. Wire message schemas
Shared from `@slopus/happy-wire`:
- from `messages.ts`: `SessionMessageContentSchema`, `SessionMessageSchema`, `MessageMetaSchema`, `SessionProtocolMessageSchema`, `MessageContentSchema` (top-level `role` union: `user|agent|session`), `UpdateNewMessageBodySchema`, `UpdateSessionBodySchema`, `UpdateMachineBodySchema`, `CoreUpdateContainerSchema`
- from `legacyProtocol.ts`: `UserMessageSchema` (`role: 'user'`), `AgentMessageSchema` (`role: 'agent'`), `LegacyMessageContentSchema` (`role`-discriminated union for legacy only)
These are used for encrypted message/update contracts (`new-message`, `update-session`, `update-machine`).
### 2. Session protocol schema
Shared from `@slopus/happy-wire`:
- `sessionEventSchema`
- `sessionEnvelopeSchema`
- `createEnvelope(...)`
- `SessionEnvelope` and related types
This is the canonical schema for the unified session protocol event stream.
Current role set in `sessionEnvelopeSchema`:
- `'user'` (user-originated envelope)
- `'agent'` (agent/system output envelopes)
Current session wire payload shape (decrypted message body):
- outer message `role` is always `'session'` for session-protocol records
- `content` is the session envelope object directly (not wrapped under `content.data`)
- envelope-level role remains inside `content.role` (`'user' | 'agent'`)
- envelope timestamp is required as `content.time` (Unix ms)
## Migration in this repository
### CLI (`packages/happy-cli`)
- Session protocol imports now reference `@slopus/happy-wire` directly.
- `src/sessionProtocol/types.ts` now re-exports from `@slopus/happy-wire` as compatibility shim.
- API wire schemas in `src/api/types.ts` now source shared message/update schemas from `@slopus/happy-wire`.
### App (`packages/happy-app`)
- Shared API message/update schemas in `sources/sync/apiTypes.ts` now import these from `@slopus/happy-wire`:
- `ApiMessageSchema`
- `ApiUpdateNewMessageSchema`
- `ApiUpdateSessionStateSchema`
- `ApiUpdateMachineStateSchema`
### Server (`packages/happy-server`)
- Prisma JSON message content type now references `SessionMessageContent` from `@slopus/happy-wire`.
- Event router uses shared `SessionMessageContent` type for `new-message` payload typing.
### Agent (`packages/happy-agent`)
- `RawMessage` now aliases `SessionMessage` from `@slopus/happy-wire`.
## Versioning model
All other workspace packages now declare a versioned dependency on `@slopus/happy-wire`.
This intentionally mirrors post-publish consumption and reduces hidden coupling to workspace-local files.
## Build and release
`@slopus/happy-wire` is configured the same way as existing publishable libraries in this repo:
- ESM/CJS/types outputs via `pkgroll`
- `build`: typecheck + bundle
- `test`: build + vitest
- `prepublishOnly`: build + test
- `release`: `release-it`
- npm publish registry configured via `publishConfig`
Use the same release entrypoint as other publishable packages:
```bash
yarn release
# choose happy-wire
```
or:
```bash
yarn workspace @slopus/happy-wire release
```
When building workspaces from a clean checkout, build `@slopus/happy-wire` first so dependent packages can resolve generated `dist` outputs.
## Publish checklist (maintainer)
1. Ensure all workspace builds/tests are green.
2. Confirm wire schema changes are backward-compatible or documented.
3. Bump and release `@slopus/happy-wire`.
4. Update downstream package versions if needed.
5. Publish dependent package updates only after the new `happy-wire` version is available.
## Notes
- `happy-wire` should stay focused on wire contracts only (types + Zod schemas + small helpers).
- Domain/business logic should remain in consumer packages.
- Keep schema additions additive where possible to minimize client breakage.
+291
View File
@@ -0,0 +1,291 @@
# Happy Layout — Core Spec
## Current Layout (from code)
Two-column layout using `expo-router/drawer` with `drawerType: 'permanent'` on tablet/desktop.
### Key files
- `SidebarNavigator.tsx` — Drawer with permanent sidebar
- `SidebarView.tsx` — left column (header + SessionsList + FABWide)
- `SessionView.tsx` — main content wrapper
- `ChatHeaderView.tsx` — abs-positioned header (maxWidth 800 on web)
- `AgentContentView.tsx` — ChatList + AgentInput
- `ChatList.tsx` — inverted FlatList of messages
- `AgentInput.tsx` — composer (maxWidth 800 on web)
- `ActiveSessionsGroupCompact.tsx` — compact session rows (56px, grouped by project)
- `layout.ts` — maxWidth constants (web: 800, mac: 1400)
### Current structure
```
┌──────────────────────────────────────────────────────────────────────────────────────┐
│ SidebarNavigator (expo-router Drawer, permanent on tablet/desktop) │
│ │
│ ┌─ SidebarView ────────┐┌─ Stack (main content) ──────────────────────────────────┐ │
│ │ width: 30% window ││ remaining width (1080px on 1440px window) │ │
│ │ clamped [250..360]px ││ │ │
│ │ ││ contentWrapper: alignItems:'center' │ │
│ │ ┌ Header 56px ─────┐ ││ ┌─ ChatHeader ─────────────────────────┐ │ │
│ │ │ H Sessions ⊕ ≡ │ ││ │ maxWidth: 800px, centered │ │ │
│ │ └──────────────────┘ ││ │ [←] Title [Avatar 32px] │ │ │
│ │ ││ │ subtitle/path │ │ │
│ │ ┌ SessionsList ────┐ ││ └──────────────────────────────────────┘ │ │
│ │ │ │ ││ │ │
│ │ │ Compact mode: │ ││ ┌─ ChatList (abs, inverted FlatList) ─┐ │ │
│ │ │ │ ││ │ maxWidth: 800px, centered │ │ │
│ │ │ [●24] ~/myapp │ ││ │ │ │ │
│ │ │ main +3-2 │ ││ │ agent message │ │ │
│ │ │ ┌──────────────┐ │ ││ │ │ │ │
│ │ │ │● fix auth │ │ ││ │ ┌────────────────────┐ │ │ │
│ │ │ │ refactor db │ │ ││ │ │ ✓ Edit auth.ts │ │ │ │
│ │ │ │ add tests │ │ ││ │ │ ✓ Edit routes.ts │ │ │ │
│ │ │ └──────────────┘ │ ││ │ └────────────────────┘ │ │ │
│ │ │ │ ││ │ │ │ │
│ │ │ [●24] ~/other │ ││ │ user message │ │ │
│ │ │ feat/x │ ││ │ │ │ │
│ │ │ ┌──────────────┐ │ ││ └─────────────────────────────────────┘ │ │
│ │ │ │ migrate │ │ ││ │ │
│ │ │ └──────────────┘ │ ││ ┌─ AgentInput ────────────────────────┐ │ │
│ │ └──────────────────┘ ││ │ maxWidth: 800px, centered │ │ │
│ │ ││ │ ┌──────────────────────────────────┐ │ │ │
│ │ ┌─ FABWide ────────┐ ││ │ │ type here... │ │ │ │
│ │ │ + New Session │ ││ │ └──────────────────────────────────┘ │ │ │
│ │ └──────────────────┘ ││ │ [⚙] [⏹] [git±] [⬆] │ │ │
│ │ ││ └──────────────────────────────────────┘ │ │
│ └──────────────────────┘└──────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────────┘
```
### Current widths
| Window | Sidebar | Main area | Chat content | Dead space each side |
|----------|---------|-----------|--------------|----------------------|
| 1440px | 360px | 1080px | 800px | ~140px |
| 1280px | 360px | 920px | 800px | ~60px |
The dead space on both sides of the chat is where the context panel goes.
---
## Proposed Layout — Three Columns
Replace `expo-router/drawer` with a custom `flexDirection:'row'` wrapper. Three children: Sidebar | Center | ContextPanel.
```
┌──────────────────────────────────────────────────────────────────────────────────────┐
│ Custom flexDirection:'row' wrapper (replaces Drawer) │
│ │
│ ┌─ SidebarView ──┐┌─ Center ──────────────────────────────────┐┌─ ContextPanel ─────┐│
│ │ width: ~300px ││ flex:1 (~840px on 1440) ││ width: ~300px ││
│ │ ││ ││ ││
│ │ ┌Header 56px─┐ ││ ┌─ ChatHeader ──────────────────────────┐ ││ [Changed] [All] ││
│ │ │H Sessions ⊕│ ││ │ [←] fix auth middleware [●Icon][◎Zen] │ ││ ││
│ │ └────────────┘ ││ │ ~/myapp │ ││ M auth.ts +3-2 ││
│ │ ││ └───────────────────────────────────────┘ ││ M routes.ts +1-1 ││
│ │ [●24] ~/myapp ││ ││ A helpers.ts +12 ││
│ │ main+3-2 ││ ┌─ ChatList ────────────────────────────┐ ││ ││
│ │ ┌────────────┐ ││ │ │ ││ ││
│ │ │● fix auth │ ││ │ 🤖 I updated the auth middleware │ ││ ││
│ │ │ refactor │ ││ │ to use isExpired() helper instead │ ││ ││
│ │ │ add tests │ ││ │ of checking token.expired directly. │ ││ ││
│ │ └────────────┘ ││ │ │ ││ ││
│ │ ││ │ ┌────────────────────────┐ │ ││ ││
│ │ [●24] ~/other ││ │ │ ✓ Edit auth.ts +3-2 │ │ ││ ││
│ │ feat/x ││ │ │ ✓ Edit routes.ts +1-1 │ │ ││ ││
│ │ ┌────────────┐ ││ │ │ ✓ Edit helpers.ts +12 │ │ ││ ││
│ │ │ migrate │ ││ │ └────────────────────────┘ │ ││ ││
│ │ └────────────┘ ││ │ │ ││ ││
│ │ ││ │ All 42 tests passing. ✓ │ ││ ││
│ │ ││ └────────────────────────────────────────┘ ││ ││
│ │ ┌────────────┐ ││ ││ ││
│ │ │+ NewSession│ ││ ┌─ AgentInput ──────────────────────────┐ ││ ││
│ │ └────────────┘ ││ │ ┌────────────────────────────────────┐ │ ││ ││
│ │ ││ │ │ type here... │ │ ││ ││
│ │ ││ │ └────────────────────────────────────┘ │ ││ ││
│ │ ││ │ [⚙] [⏹] [git±] [⬆] │ ││ ││
│ │ ││ └────────────────────────────────────────┘ ││ ││
│ └────────────────┘└────────────────────────────────────────────┘└────────────────────┘│
│ ◁━━━━━━ drag edges to resize ━━━━━━▷ │
└──────────────────────────────────────────────────────────────────────────────────────┘
```
### Design decisions
**Both sidebars same width (~300px).** Center stays visually centered. At 1440px the center is ~840px — naturally close to the current 800px maxWidth, making it redundant. Drop maxWidth constraints on chat content and header; let them fill the center column.
**Main content should be resizable.** P1 Drag left edge to widen/shrink center.
**Side bars should also be resizable** P2
**Context panel is per-worktree.** Switch sessions in the same worktree, right panel stays the same. Changes, files — all scoped to the worktree.
**Zen mode is the only toggle.** Both sidebars always visible on desktop. `Cmd+0` or zen button hides both, center goes full width. No individual panel toggles.
Zen will toggle even more off! it should be like Bear markdown editor - full attention to content - 0 distractions
**Zen button lives in the center header.** Next to the session avatar, top right of the center column — not at the window edge.
### Center header row
```
[←] Session Title [●32 avatar] [◎ Zen]
~/path/to/project
```
- `[←]` back button (existing)
- Title + subtitle (existing)
- `[●32]` session avatar with popover menu (existing)
- `[◎ Zen]` zen mode toggle (new)
### AgentInput (composer) in active session
```
┌──────────────────────────────────────────────┐
│ type here... │
└──────────────────────────────────────────────┘
[⚙ settings][⏹ abort] [git±] [⬆]
```
- `[⚙]` opens settings overlay (permission mode, model, effort)
- `[⏹]` abort current operation
- `[git±]` git status badge — opens file viewer
- `[⬆]` send button (32x32, right-aligned)
---
## Context Panel (Right)
### Sub-tabs
Two tabs at the top: **Changed** | **All**
#### Changed (build first)
Git diff file list for the worktree. Modified/added/deleted files with line counts.
```
M src/auth.ts +3 -2
M src/routes.ts +1 -1
A src/helpers/token.ts +12
```
Click a file → Center Column becomes the unified diff viewer, scrolled to that file.
#### All (later)
Full hierarchical file tree browser. ONLY browse - when opening the files - we should be able to edit files tho.
Edit on desktop only!
#### Aspirational (not in v1, not in tabs)
Everyone loves arc ... chrome is bringing vertical tabs ... but conductor is pushing the industry back into horizontal. Happy to bring you relief!
Below the tab content, future additions:
- **Important tab** — "working set" skeleton of files the agent recently read/wrote
- **Guided Tour** — onboarding walkthrough entry point, inspired by [Graphite Code Tours](https://graphite.com/blog/code-tours)
- **Pipeline / Flow** — ACP loops, custom pipelines, CI status
- **Terminals** — active terminals on the machine, with ports - maybe even can open them
---
## Diff Viewer (Center)
When a file is clicked in the context panel, center replaces the chat with a single scrollable diff surface.
```
┌──────────────────────────────────────────────────────────────────────────────────────┐
│ ┌─ SidebarView ──┐┌─ Center (diff mode) ──────────────────────┐┌─ ContextPanel ────┐│
│ │ ││ ← Back to chat Diff (3 files +16-3)│ ││
│ │ (unchanged) ││ ││ [Changed] [All] ││
│ │ ││ ┄┄ src/auth.ts ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ ││ ││
│ │ ││ (scroll up to see) ││ src/auth.ts +3-2 ││
│ │ ││ ││ ▶src/routes.ts+1-1 ││
│ │ ││ ┄┄ src/routes.ts ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ ││ src/helpers +12 ││
│ │ ││ ││ ││
│ │ ││ 51 │ import { newAuth } from './auth' ││ ││
│ │ ││ 52 │ ││ ││
│ │ ││ 53 │- app.use(oldAuth) ││ ││
│ │ ││ │+ app.use(newAuth) ││ ││
│ │ ││ 54 │ ││ ││
│ │ ││ ││ ││
│ │ ││ ──── Unchanged (8 lines) ──── ││ ││
│ │ ││ ││ ││
│ │ ││ ┄┄ src/helpers.ts (new file) ┄┄┄┄┄┄┄┄┄┄ ││ ││
│ │ ││ ││ ││
│ │ ││ 1 │+ export function isExpired(token) { ││ ││
│ │ ││ 2 │+ return Date.now() > token.exp ││ ││
│ │ ││ 3 │+ } ││ ││
│ │ ││ ││ ││
│ │ ││ ┄┄ end ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ ││ ││
│ └────────────────┘└────────────────────────────────────────────┘└────────────────────┘│
└──────────────────────────────────────────────────────────────────────────────────────┘
```
- One continuous scrollable surface for all changed files (like GitHub PR "Files changed")
- File headers as sticky section dividers
- Context panel index highlights current file as you scroll
- Click file in index → smooth scroll to that section
- Collapsed unchanged regions (click to expand)
- `← Back to chat` returns to session at same scroll position
### Zen mode
```
┌──────────────────────────────────────────────────────────────────────────────────────┐
│ Center only — full window width │
│ │
│ [←] fix auth middleware [●32 avatar] [◎ Zen] │
│ ~/myapp │
│ │
│ 🤖 I updated the auth middleware to use the new │
│ isExpired() helper instead of checking token.expired │
│ directly. This fixes the race condition... │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ ✓ Edit src/auth.ts +3 -2 │ │
│ │ ✓ Edit src/routes.ts +1 -1 │ │
│ │ ✓ Edit src/helpers.ts +12 │ │
│ └────────────────────────────────────────┘ │
│ │
│ All 42 tests passing. ✓ │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ type here... │ │
│ └────────────────────────────────────────────────┘ │
│ [⚙] [🤖claude] [⏹] [git±] [⬆] │
│ │
└──────────────────────────────────────────────────────────────────────────────────────┘
```
In zen mode, chat content should not change automatically - less shifting around. We should allow resizing in this mode tho too.
---
## Mobile (later — not v1)
Two-push navigation from session:
1. Push index screen (context panel content, full screen)
2. Tap file → push full diff, auto-scrolled to that file, sticky mini-nav at bottom
---
## Implementation notes
- Replace `expo-router/drawer` in SidebarNavigator with custom three-column flex layout
- Drop `layout.maxWidth` constraint on chat content and header — center column provides the constraint
- Re-apply maxWidth in zen mode so content doesn't stretch
- Context panel state stored per-worktree in zustand
- Persist sidebar widths + zen state per user in settings
- Desktop first, mobile later
- Diff viewer - lets look for a trendy package - we probably just want to show a web view on mobile to avoid dealing with native shit :D. Ideally we need to have file edit ops / code previews similar styled
## Related improvements (not layout-specific)
- Better table rendering in agent messages
- Clickable file paths in agent output
- Richer inline tool output (syntax-highlighted diffs in collapsed tool calls)
- Fix black stripe artifact in file edit rendering
- Fix duplicated plan presentation
- Navigation bugs: back nav broken in logout/restore flows
- Workspaces & Checkouts: see roadmap.md
- Push notification routing: see roadmap.md
+330
View File
@@ -0,0 +1,330 @@
# Multi-process happy-server
How handy-server runs across multiple Kubernetes replicas: socket distribution,
room-based RPC routing, broadcast fan-out, daemon lifecycle, and what happens
during the messy cases (pod kill, brief reconnect, network partition).
For the shorter high-level control-flow doc, see `realtime-sync-and-rpc.md`.
> **Status:** the code in this doc is on `main` but `handy.yaml` ships
> `replicas: 1`. Flipping prod to multi-replica is a separate decision.
## TL;DR
handy-server uses the **Socket.IO Redis streams adapter** to forward
`io.to(...).emit(...)` between replicas through a single Redis stream. RPC
routing (web → daemon) goes through **Socket.IO rooms** named
`rpc:<userId>:<method>`. The server resolves the daemon socket via
`io.in(room).fetchSockets()` (the cluster-adapter primitive that works
cross-replica), and sends the request to a single RemoteSocket. There is **no
Redis key, no TTL, no Lua-CAS cleanup, no keep-alive refresh path** —
membership is standard Socket.IO room state, cleaned up automatically on
disconnect.
If the daemon is briefly offline at call time (k8s pod cycling, transient
network drop), the server **waits up to 10 seconds** for it to reappear before
failing. If the daemon is in flight when its socket dies, a **presence poll**
aborts the call within ~1 second instead of waiting the full 30s
emit-with-ack timeout.
`connectionStateRecovery` is **commented out** in `socket.ts`. The streams
adapter supports it (verified working) but we ship parity with the
pre-multi-process behavior first; clients still do a full REST re-fetch on
every reconnect via `apiSocket.onReconnected`.
## What an rpc-call does (control flow)
```
rpc-call from web client
.
├── input validation
│ └── method name → invalid → callback({ok:false, error:'Invalid parameters'})
├── 1. resolve target via cluster adapter
│ └── fetchRoomSockets(io, 'rpc:<userId>:<method>')
│ ├── io.in(room).timeout(500ms).fetchSockets()
│ ├── on success → returns [...]
│ └── on failure (peer replica unresponsive, fast adapter timeout)
│ └── log + return [] (treat as "nobody here")
│ │
│ ├── returns [target] → go to step 2
│ └── returns [] → go to wait-for-reconnect
├── wait-for-reconnect grace (only when no target found)
│ └── waitForRoomMember(io, room, 10_000ms)
│ └── poll every 200ms via fetchRoomSockets:
│ ├── room gained a member → return [target]
│ └── deadline reached → return []
│ │
│ ├── grace produced [target] → go to step 2
│ └── grace produced []
│ └── callback({ok:false, error:'RPC method not available'})
├── 2. sanity checks on resolved target
│ ├── multiple sockets in room → log warn, use first
│ └── target.id === socket.id → callback({ok:false, error:'same socket'})
├── 3. fire emit + race a presence poll
│ ├── ackPromise = target.timeout(30_000).emitWithAck('rpc-request', ...)
│ │ (cluster adapter routes cross-replica via Redis stream)
│ │
│ └── presencePoll = while (alive)
│ └── sleep 1s, fetchRoomSockets again
│ ├── target still in room → keep watching
│ └── target absent → throw 'RPC target disconnected'
├── Promise.race(ackPromise, presencePoll)
│ ├── ackPromise resolves → callback({ok:true, result})
│ ├── ackPromise throws (timeout / err) → callback({ok:false, error: msg})
│ └── presencePoll throws → callback({ok:false, error:'RPC target disconnected'})
└── finally
└── presenceAlive = false (stops the poll cleanly on success or failure)
```
## What a daemon does (lifecycle)
```
daemon (machine-scoped or session-scoped)
.
├── connect to handy-server
│ └── server: socket.handshake.auth.token → auth.verifyToken
│ └── attaches rpcHandler / *UpdateHandler / etc
├── emit('rpc-register', { method })
│ └── server: socket.join('rpc:<userId>:<method>')
│ └── ack: emit('rpc-registered', { method })
│ (Socket.IO room state, NO Redis key, NO TTL)
├── on('rpc-request', (data, cb) => …)
│ └── handler runs, cb(result) returns the value via the cluster adapter
├── disconnect (any reason)
│ └── Socket.IO automatically removes the socket from all rooms
│ (cluster adapter syncs via heartbeat; no manual cleanup needed)
└── auto-reconnect
└── on 'connect': re-emit rpc-register
(the only client-side responsibility)
```
## What a broadcast does (event emission)
```
eventRouter.emitUpdate / emitEphemeral
.
└── io.to(rooms).emit('update' | 'ephemeral', payload)
├── streams adapter: XADD on the 'socket.io' Redis stream
│ (MAXLEN ~ 50000, auto-trimmed by Redis)
└── every replica's XREAD loop picks up the entry
└── delivers to its local sockets that match the room set
(sockets that disconnected before the emit miss it; client
falls through to apiSocket onReconnected → REST refetch)
```
Rooms used by `eventRouter`:
```
.
├── user:<userId> all of a user's sockets
├── user:<userId>:user-scoped only the web/desktop clients
├── user:<userId>:session:<sessionId> session-scoped subscribers
└── user:<userId>:machine:<machineId> one specific machine
```
## Where the code lives
```
.
├── packages/happy-server/sources/app/
│ ├── api/socket.ts io.Server setup, attaches the
│ │ streams adapter when REDIS_URL
│ │ is set, commented-out
│ │ connectionStateRecovery
│ ├── api/socket/rpcHandler.ts the entire RPC routing layer
│ │ (~180 lines, single code path)
│ ├── api/socket/machineUpdateHandler.ts no longer touches RPC state
│ ├── api/socket/sessionUpdateHandler.ts no longer touches RPC state
│ └── events/eventRouter.ts broadcast emission via rooms
└── packages/happy-server/deploy/handy.yaml k8s Deployment + Service
(replicas: 1 in this PR)
```
## What was wrong before (the four bugs)
The previous attempt stored RPC routing state as `rpc:user:<u>:method:<m>`
socketId Redis keys with a 60-second TTL refreshed by `machine-alive` /
`session-alive` heartbeats. This had three killer bugs (smoking gun was #3):
```
.
├── #1 In-flight RPC eats the full 30s timeout when the target pod dies
│ io.to(deadSocketId).emitWithAck() has no fast-fail.
│ FIX: presence poll aborts within ~1s
├── #2 Reconnect race
│ Between the daemon's disconnect cleanup and re-register, ~57% of
│ cross-pod RPCs fail with either "method not available" (key
│ deleted) or "target not reachable" (key still pointed at dead
│ socketId).
│ FIX: atomic socket.join / auto-leave on disconnect, no race window
├── #3 Silent TTL expiry
│ Daemon stays connected, registration vanishes after 60s if the
│ keep-alive event was missed for any reason. Daemon never knows;
│ stays broken until reconnect.
│ FIX: no TTL exists anymore
└── #4 Streams adapter "unbounded growth"
FALSE ALARM. The adapter trims with MAXLEN ~ on every XADD. Capped
at ~50k entries. Crossing this off the list.
```
The full postmortem with reproduction commands is at
`deploy/integration-tests/POSTMORTEM.md`.
## How we tested it
Local minikube with a 2-replica handy-server, Redis, Postgres, exposed as a
real `LoadBalancer` service via `minikube tunnel`. All harnesses live in
`deploy/integration-tests/`.
```
.
├── test-rpc-cross-replica.mjs steady-state cross-pod RPC
│ (50 parallel + 20 sequential)
├── test-multiprocess.mjs broadcast fan-out + pod-kill recovery
├── hammer.mjs <scenario> pod-kill-mid-rpc, reconnect-storm,
│ ttl-expiry, brief-disconnect,
│ long-disconnect
├── network-loss.mjs long-running RPC loop with summary,
│ usable with iptables blackouts
├── missed-events.mjs brief disconnect → triggered broadcast →
│ reconnect; verifies missed-events
│ behavior matches main (lost from socket,
│ recovered via REST refetch)
├── probe-rpc.mjs direct rpc-register sanity probe +
│ Redis key inspector
├── probe-fetchsockets.mjs fetchSockets latency probe
├── POSTMORTEM.md full bug-by-bug
└── ../local.sh bring up the whole minikube stack
```
To bring up the test environment from scratch:
```bash
deploy/local.sh # provisions stack
kubectl get pods -l app=handy-server # confirm 2 replicas
kubectl patch svc handy-server -p '{"spec":{"type":"LoadBalancer"}}'
minikube tunnel & # exposes :3000
node deploy/integration-tests/test-rpc-cross-replica.mjs
```
Final gauntlet result against the fix:
```
.
├── steady-state cross-pod RPC 50/50 + 20/20 ✅ (after ~5s warmup)
├── pod-kill-mid-rpc 1612ms fast-fail ✅ (was 30000ms)
├── brief-disconnect SUCCESS in 2011ms ✅
├── long-disconnect bounded 10542ms ✅ (10s grace + ~0.5s)
├── ttl-expiry (smoking gun) ALL 5 calls pass through +75s ✅
├── reconnect-storm (5 cycles) 9697% success ✅ (only inherent
│ in-flight failures, ~3%)
├── broadcast multi-process 20/20 fan-out, 5/5 unaffected ✅
├── network-loss 60s loop 85/85 zero failures ✅
└── missed-events parity event lost via socket, in DB,
recovered=undefined ✅ (matches main)
```
## Tunable constants
```
RPC_RECONNECT_GRACE_MS 10_000 wait-for-reconnect window (2× heartbeat)
RPC_RECONNECT_POLL_MS 200 poll cadence inside the grace
RPC_PRESENCE_POLL_MS 1_000 presence-poll cadence during in-flight
RPC_PRESENCE_FETCH_TIMEOUT_MS 500 per-call cross-replica fetchSockets cap
RPC_CALL_TIMEOUT_MS 30_000 upper bound on emitWithAck — same as main
(no support for >30s RPCs in either)
```
## Adapter details and limits worth knowing
```
.
├── streams adapter discovery
│ ~5s after a pod starts, the adapter's heartbeat exchange means
│ cross-replica fetchSockets() may not see all rooms. First few RPCs
│ immediately after a fresh rollout can hit the wait-for-reconnect
│ grace; we sized RPC_RECONNECT_GRACE_MS at 10s to cover 2 heartbeat
│ cycles.
├── MAXLEN ~ 50000
│ configured in socket.ts. Auto-trims on every XADD, no cleanup needed.
├── fetchSockets() cross-replica
│ defaults to a 5-second timeout per request. We pass timeout(500) for
│ our presence polls so a single unresponsive replica doesn't stall
│ every poll for 5s.
├── emitWithAck from a RemoteSocket
│ works cross-replica through the cluster adapter (the streams adapter
│ inherits ClusterAdapterWithHeartbeat which implements BROADCAST_ACK
│ and FETCH_SOCKETS_RESPONSE).
└── multiple sockets in the same RPC room
shouldn't happen in practice (one daemon per machine, one method
registration). If it does, we log a warn and pick targets[0]. Same
blast radius as the previous Redis last-write-wins behavior.
```
## What we still don't do (intentional, deferred)
```
.
├── connectionStateRecovery
│ Commented out in socket.ts. Enabling it would let brief disconnects
│ skip the heavy REST refetch (events replay through the streams
│ adapter via restoreSession). Verified working — not shipped to
│ preserve parity with main on this dimension.
├── In-flight RPC continuity across daemon reconnect
│ Coupled to the above. With connectionStateRecovery enabled AND a
│ recovery-aware presence poll (i.e. "wait N seconds for the same
│ socketId to come back before failing"), an in-flight RPC could
│ survive a brief network blip on the daemon: the daemon's handler
│ keeps running, the ack packet sits in the client's sendBuffer,
│ reconnect flushes it, the caller gets its result. Today the presence
│ poll fast-fails the call as soon as the room is empty, which kills
│ this case. Out of scope for this PR.
├── User-affinity routing at the LB
│ Cross-pod RPC overhead is ~36ms via the streams adapter. JWT-aware
│ routing (Envoy / Istio / nginx-lua) would be a bigger infra change
│ than the fix itself. Tracked as future-work.
├── UI "reconnecting…" indicator
│ Server now waits 10s for daemons. Client doesn't yet show that wait
│ in the UI. apiSocket-side change, separate from this PR.
├── Tuning the adapter discovery window
│ 5s is the streams adapter's default heartbeatInterval. Lowering it
│ would reduce the fresh-pod-startup race but increase Redis chatter.
└── Long-running RPCs (> 30s)
Not supported on either main or this PR. Bash command in the CLI has
its own 30s cap that races dead-even with the server's 30s emit
timeout. Bumping requires both server and (possibly added) client
timeouts.
```
## Reference
- Socket.IO rooms: <https://socket.io/docs/v4/rooms/>
- `fetchSockets()`: <https://socket.io/docs/v4/server-api/#serverfetchsockets>
- Broadcasting events: <https://socket.io/docs/v4/broadcasting-events/>
- Memory usage: <https://socket.io/docs/v4/memory-usage/>
- Streams adapter source: <https://github.com/socketio/socket.io-redis-streams-adapter>
- Connection state recovery: <https://socket.io/docs/v4/connection-state-recovery>
- Discussion #5062 (broadcast emitWithAck waits for all): <https://github.com/socketio/socket.io/discussions/5062>
+79
View File
@@ -0,0 +1,79 @@
# Paid Voice — Rate Limiting & Auth
## Flow
```
User taps mic
├─ Bypass mode? (custom agent ID)
│ └─ connect directly to ElevenLabs, skip everything
├─ POST /v1/voice/conversations { agentId }
│ │
│ ├─ GET /v1/convai/conversations?agent_id=X&user_id=Y&created_after=<30d>&page_size=100
│ │ └─ Sum call_duration_secs → usedSeconds (~108ms)
│ │
│ ├─ conversations == 100? → { allowed: false, reason: "voice_conversation_limit_reached" }
│ ├─ usedSeconds >= 5h? → { allowed: false, reason: "voice_hard_limit_reached" }
│ ├─ usedSeconds >= 20min + no sub? → { allowed: false, reason: "subscription_required" }
│ │
│ ├─ GET /v1/convai/conversation/token?agent_id=X&participant_name=ELEVEN_USER_ID
│ │ └─ Decode JWT → extract conv_id from video.room
│ │
│ └─ Return { conversationToken, conversationId, agentId, elevenUserId, usedSeconds, limitSeconds }
├─ allowed: false?
│ ├─ "voice_conversation_limit_reached" → alert (file issue on GitHub)
│ └─ other → paywall flow="voice_must_pay"
└─ allowed: true
├─ feature flag voice-upsell == "show-paywall-before-first-voice-chat"?
│ └─ first free voice start only → soft paywall flow="voice_trial_eligible"
├─ feature flag voice-upsell == "voice-onboarding-and-upsell"?
│ └─ inject onboarding + upsell guidance into voice prompt
└─ otherwise
└─ control → no soft paywall and no onboarding experiment
then startSession({ conversationToken }) → WebRTC via LiveKit
```
## Limits
| Tier | Limit | Window | Cost to us | What happens |
|------|-------|--------|------------|--------------|
| Free | 20 min | 30 days | ~$0.19 | Paywall |
| Subscribed | 5 hours | 30 days | — | Hard block → BYO agent |
| BYO Agent | Unlimited | — | $0 | User's own ElevenLabs |
| Any | 100 conversations | 30 days | — | Hard block → file issue |
Cost: ~$0.01/min ($1600 / 171K min measured).
## Tracking
ElevenLabs is the source of truth. No local DB.
- `participant_name` on token mint → sets `user_id` on conversation record
- Usage: `GET /conversations?user_id=Y&created_after=<30d>&page_size=100` → sum durations
- `user_id` = HMAC-SHA256 of Happy user ID (deterministic, one-way)
- Max page_size is 100 → at 100 conversations we block (can't track more without pagination)
**TODO:** Remove `VoiceConversation` model from Prisma schema (no longer used, DB table can be dropped).
## Paywall Flows (RevenueCat)
Single paywall template, rules driven by custom variable `flow`:
| Flow | When | Behavior |
|------|------|----------|
| `voice_trial_eligible` | Feature flag variant `show-paywall-before-first-voice-chat`, first free voice use | Soft — dismissable, voice starts anyway |
| `voice_must_pay` | Server returns `allowed: false` | Hard — must purchase |
| `voluntary_support` | Settings | User-initiated |
### Future: Voice Agent Self-Sell
Have the agent mention pricing naturally. Inject `usedSeconds`/`limitSeconds` into context, add `showUpgradePaywall` client tool.
## Security
- JWT signed by ElevenLabs, single-use, can't be forged
- Agent set to "authorized only" — needs server-minted token
- Agent ID in public repo is harmless
+100
View File
@@ -0,0 +1,100 @@
# Permission Resolution (State-Based)
This document explains how permission mode is resolved for session messages, depending on current state in the app and CLI.
## Scope
- App-side state resolution (session defaults, persisted values, outbound message metadata)
- Claude CLI resolution (startup mode, per-message updates, sandbox policy)
- Final mode sent to Claude SDK
## Permission Modes
- Shared mode type: `default | acceptEdits | bypassPermissions | plan | read-only | safe-yolo | yolo`
- Claude SDK supports: `default | acceptEdits | bypassPermissions | plan`
- Mapping to Claude happens in `packages/happy-cli/src/claude/utils/permissionMode.ts`:
- `yolo -> bypassPermissions`
- `safe-yolo -> default`
- `read-only -> default`
## App-Side Resolution
### 1) Session state load/merge
`packages/happy-app/sources/sync/storage.ts`
When sessions are merged, the app resolves `session.permissionMode` using this order:
1. Existing in-memory session mode (if non-`default`)
2. Persisted per-session mode from local storage (if non-`default`)
3. Mode from server session payload (if non-`default`)
4. Sandbox fallback:
- If `session.metadata.sandbox.enabled === true`: `bypassPermissions`
- Otherwise: `default`
### 2) New-session draft fallback
`packages/happy-app/sources/sync/persistence.ts`
If draft permission mode is missing:
- Draft default: `default`
### 3) New session UI defaults
`packages/happy-app/sources/app/(app)/new/index.tsx`
`packages/happy-app/sources/components/NewSessionWizard.tsx`
Default selection:
- `default`
If selected mode is invalid for the currently selected agent, UI resets to agent default above.
### 4) Outbound message mode
`packages/happy-app/sources/sync/sync.ts`
On send:
- If `session.permissionMode` is non-`default`, send it.
- Otherwise:
- If `session.metadata.sandbox.enabled === true`: send `bypassPermissions`
- Else send `default`
This value is sent in:
- encrypted message `meta.permissionMode`
- socket envelope `permissionMode`
## Claude CLI Resolution
### 1) Startup resolution
`packages/happy-cli/src/claude/runClaude.ts`
`packages/happy-cli/src/claude/utils/permissionMode.ts`
Initial mode comes from:
1. `--dangerously-skip-permissions` (highest priority) -> `bypassPermissions`
2. `--permission-mode VALUE` or `--permission-mode=VALUE`
3. Provided `options.permissionMode`
Then sandbox policy is applied:
- If sandbox enabled: force `bypassPermissions`
- If sandbox disabled: keep resolved mode
### 2) Per-message updates in remote flow
`packages/happy-cli/src/claude/runClaude.ts`
When a user message includes `meta.permissionMode`:
- If sandbox enabled: forced to `bypassPermissions`
- If sandbox disabled: use incoming mode
### 3) Local Claude process
`packages/happy-cli/src/claude/claudeLocal.ts`
If sandbox is enabled, launcher appends `--dangerously-skip-permissions` before spawn.
## Effective Result Matrix
### Sandbox enabled
- App fallback mode is `bypassPermissions` when session mode is default/missing
- Claude CLI sandbox policy still forces `bypassPermissions` in remote flow
### Sandbox disabled
- If app/session mode is non-`default`: that mode is used
- If app/session mode is `default` or missing:
- App sends `default`
- CLI uses normal mode resolution (no sandbox forcing)
## Why this is stable now
- Client fallback only forces skip-permissions for sandboxed sessions.
- CLI sandbox policy guarantees sandboxed Claude sessions cannot re-enable permission prompts via message metadata.
@@ -0,0 +1,183 @@
# Agent SDK Upgrade + Plan Mode Fix + Integration Tests
## Context
Plan mode buttons (accept/reject) don't reliably appear in the Happy app UI. Root cause: the CLI's `permissionHandler.handleToolCall` auto-approves ExitPlanMode when `permissionMode` is stale (`bypassPermissions` from a prior session), and `reset()` never clears `permissionMode`. Additionally, `isAborted` always returns true for ExitPlanMode as part of a PLAN_FAKE_RESTART hack, which causes a dead-end when the tool is auto-approved without injecting a restart message.
The current custom SDK (`src/claude/sdk/`) reimplements what `@anthropic-ai/claude-agent-sdk` now provides natively, including `toolUseID` in `canUseTool` (eliminates our 1-second delay hack) and `setPermissionMode()` (eliminates the session-restart-on-mode-change hack).
### Control flow with the bug
```
[CLI] handleToolCall(ExitPlanMode, ...)
├─ permissionMode === 'bypassPermissions'? ← stale from prior session
│ └─ YES → return allow ✅ ← 🐛 skips permission UI
│ handlePermissionResponse NEVER called
│ └─ PLAN_FAKE_RESTART never injected
└─ isAborted(ExitPlanMode) → always true
└─ claudeRemote exits → queue empty → stuck
```
### The fix (Part 1 — shipped)
ExitPlanMode now always goes through the permission request flow, before the `bypassPermissions`/`acceptEdits` shortcuts:
```typescript
// permissionHandler.ts handleToolCall()
const descriptor = getToolDescriptor(toolName);
if (descriptor.exitPlan) {
// Always ask — never auto-approve plan exit
return this.handlePermissionRequest(toolCallId, toolName, input, options.signal);
}
if (this.permissionMode === 'bypassPermissions') { ... } // other tools still bypass
```
---
## Part 1: Immediate Bug Fix ✅
**File:** `packages/happy-cli/src/claude/utils/permissionHandler.ts`
ExitPlanMode must never be auto-approved. In `handleToolCall`, check `descriptor.exitPlan` before the `bypassPermissions`/`acceptEdits` shortcuts and always route to `handlePermissionRequest`.
---
## Part 2: Agent SDK Migration
### 2a. Install dependency
```bash
cd packages/happy-cli && yarn add @anthropic-ai/claude-agent-sdk@^0.2.96
```
### 2b. Migrate SDK types
**File:** `src/claude/sdk/types.ts`
Replace custom types with re-exports from the Agent SDK:
- `SDKMessage` → from agent-sdk
- `CanCallToolCallback``CanUseTool` from agent-sdk (now receives `options.toolUseID`)
- `QueryOptions` → from agent-sdk `Options`
- `PermissionResult` → from agent-sdk
- Keep any Happy-specific extensions as wrappers
### 2c. Migrate query.ts
**File:** `src/claude/sdk/query.ts`
Replace our custom `Query` class + `query()` function with the Agent SDK's `query()`:
- The Agent SDK's `query()` returns a `Query` that is `AsyncGenerator<SDKMessage>` with control methods
- It handles process spawning, stdin/stdout, control requests internally
- Our `handleControlRequest` / `processControlRequest` / `cleanupControllers` all become unnecessary
Key mapping:
| Current (custom) | Agent SDK |
|---|---|
| `canCallTool: (name, input, opts) => ...` | `canUseTool: (name, input, opts) => ...` |
| opts has `{ signal }` | opts has `{ signal, toolUseID, suggestions, title, ... }` |
| Manual `--permission-prompt-tool stdio` arg | Handled automatically when `canUseTool` provided |
### 2d. Use toolUseID from canUseTool
**File:** `src/claude/utils/permissionHandler.ts`
The `canUseTool` callback now receives `options.toolUseID`. Remove:
- `resolveToolCallId()` method and the 1-second delay retry hack
- `toolCalls[]` tracking array
- `onMessage()` method (no longer needed for tool ID resolution)
### 2e. Use setPermissionMode for mode changes
**File:** `src/claude/claudeRemote.ts`
Store the `Query` object so we can call `query.setPermissionMode()` when mode changes arrive, instead of killing and restarting the session.
**File:** `src/claude/claudeRemoteLauncher.ts`
When a permission mode change is detected in `nextMessage()`:
- Instead of returning null (triggering restart), call `query.setPermissionMode(newMode)`
- Update `permissionHandler.handleModeChange()` as before
- Continue processing — no restart needed
**Caveat:** Model changes still require restart (different `--model` flag). Keep hash-based detection for model changes only.
---
## Part 3: Plan Mode Hack Cleanup
Depends on Part 2 being complete.
### 3a. Remove PLAN_FAKE_RESTART / PLAN_FAKE_REJECT
**File:** `src/claude/sdk/prompts.ts` — delete entirely
**File:** `src/claude/utils/permissionHandler.ts` — in `handlePermissionResponse`:
- When plan approved: call `setPermissionMode(approvedMode)` on the query, then return `{ behavior: 'allow' }`
- When plan denied: return `{ behavior: 'deny', message: reason }`
- No more fake messages, no more queue injection
### 3b. Remove isAborted always-true for ExitPlanMode
**File:** `src/claude/utils/permissionHandler.ts` — in `isAborted()`:
- Remove the special case for exit_plan_mode
- Only return true when `responses.get(toolCallId)?.approved === false`
### 3c. Remove tool result content transformation
**File:** `src/claude/claudeRemoteLauncher.ts` — remove the PLAN_FAKE_REJECT → "Plan approved" transformation in `onMessage()` (lines 196-224)
### 3d. Remove planModeToolCalls tracking
**File:** `src/claude/claudeRemoteLauncher.ts` — remove `planModeToolCalls` Set and detection logic
---
## Part 4: Integration Tests ✅
**File:** `src/claude/planMode.integration.test.ts`
Three tests against an isolated `/tmp/happy-testing-ground-<random>/` fixture with `hello-world.js` in a git repo:
1. **Plan approval** → ExitPlanMode received by canCallTool, plan input has content, Claude edits the file
2. **Plan denial** → file untouched after deny
3. **Regression** → ExitPlanMode always reaches canCallTool (the bug we found)
**File:** `src/testing/planModeTestFixture.ts` — creates the isolated fixture
**File:** `vitest.config.ts` — new `integration-plan-mode` project with 180s timeout
---
## Hacks inventory (to remove in Parts 2-3)
| Hack | Location | Why it exists | SDK replacement |
|------|----------|--------------|----------------|
| `PLAN_FAKE_RESTART` / `PLAN_FAKE_REJECT` | `sdk/prompts.ts`, `permissionHandler.ts`, `claudeRemoteLauncher.ts` | SDK doesn't support plan mode exit natively | `setPermissionMode()` + allow/deny |
| `resolveToolCallId` + 1s delay | `permissionHandler.ts:156-163` | Race between permission request and message processing | `toolUseID` in `canUseTool` options |
| `isAborted` always-true for ExitPlanMode | `permissionHandler.ts:342-346` | Prevents SDK from executing the fake-rejected tool | Not needed when approval is real |
| Tool result content transformation | `claudeRemoteLauncher.ts:196-224` | Converts PLAN_FAKE_REJECT to "Plan approved" for logs | Not needed when flow is clean |
| Hash-based mode change → kill session → restart | `claudeRemoteLauncher.ts:372-378` | SDK doesn't restart on mode change | `setPermissionMode()` mid-session |
---
## Files modified (summary)
| File | Change |
|---|---|
| `package.json` | Add `@anthropic-ai/claude-agent-sdk` dependency |
| `src/claude/sdk/types.ts` | Re-export Agent SDK types |
| `src/claude/sdk/query.ts` | Replace with Agent SDK wrapper |
| `src/claude/sdk/prompts.ts` | Delete (PLAN_FAKE constants) |
| `src/claude/sdk/index.ts` | Update exports |
| `src/claude/utils/permissionHandler.ts` | Fix bug, use toolUseID, remove hacks |
| `src/claude/utils/getToolDescriptor.ts` | No change (already correct) |
| `src/claude/claudeRemote.ts` | Use setPermissionMode, store query ref |
| `src/claude/claudeRemoteLauncher.ts` | Remove plan mode hacks, simplify mode changes |
| `src/claude/planMode.integration.test.ts` | New — plan mode tests |
| `src/testing/planModeTestFixture.ts` | New — test fixture helper |
| `vitest.config.ts` | Add plan mode test suite |
+28
View File
@@ -0,0 +1,28 @@
# Agent Testing Layers
Keep this simple.
## Layer 1
Layer 1 is direct integration with the real agent.
Source of truth:
- `packages/happy-cli/agents.md`
## Layer 2
Layer 2 is full Happy product validation.
Source of truth:
- `./product.md`
## happy-agent spawn
`happy-agent spawn` is product-layer validation work.
Source of truth:
- `./happy-agent.md`
- `packages/happy-agent/src/happy-agent.integration.test.ts`
+145
View File
@@ -0,0 +1,145 @@
# CLI V3 Messages API Migration (happy-cli)
## Overview
Migrate `happy-cli`'s `ApiSessionClient` from Socket.IO-based message read/write to the new v3 HTTP endpoints. The client will:
- **Send messages** via `POST /v3/sessions/:sessionId/messages` using InvalidateSync to batch outgoing messages from an outbox — fixes the current problem where messages are silently lost on disconnect
- **Receive messages** via `GET /v3/sessions/:sessionId/messages?after_seq=X` with cursor-based polling, triggered by Socket.IO event invalidation
- **Track seq per session** — store `lastSeq` from server responses, use it for incremental fetches
- **Fast-path for consecutive events** — when a Socket.IO `new-message` event arrives with `seq === lastSeq + 1`, apply it directly. On gap, invalidate to trigger server fetch.
This replaces the current fire-and-forget `socket.emit('message', ...)` (5 separate send methods all using this pattern) and the direct Socket.IO `update` event handler for receiving.
## Context (from discovery)
- **Package**: `packages/happy-cli/src/api/apiSession.ts``ApiSessionClient` class (EventEmitter)
- **Current send methods** (all use `socket.emit('message', { sid, message: encrypted })`):
- `sendClaudeSessionMessage(body)` — Claude JSONL output
- `sendCodexMessage(body)` — Codex messages
- `sendSessionProtocolMessage(envelope)` — Session protocol envelopes
- `sendAgentMessage(provider, body)` — ACP unified format (Gemini, Codex, Claude, OpenCode)
- `sendSessionEvent(event)` — Events (switch, message, permission-mode, ready)
- **Current receive**: Socket.IO `update` event → decrypt → parse as `UserMessage` → forward to `pendingMessageCallback` or buffer in `pendingMessages` array
- **Known bug**: Messages silently lost when socket disconnected (see TODO in `sendCodexMessage` line 275)
- **Encryption**: `encrypt(key, variant, data)` → Uint8Array → `encodeBase64()` → string (same format v3 POST expects as `content`)
- **HTTP client**: axios (not fetch) — used throughout the codebase
- **InvalidateSync**: Available in `src/utils/sync.ts` (identical to happy-app's version)
- **AsyncLock**: Available in `src/utils/lock.ts`
- **Tests**: vitest with mocked socket.io-client in `apiSession.test.ts`
## Development Approach
- **Testing approach**: Regular (code first, then tests)
- Complete each task fully before moving to the next
- Make small, focused changes
- **CRITICAL: every task MUST include new/updated tests**
- **CRITICAL: all tests must pass before starting next task**
- **CRITICAL: update this plan file when scope changes during implementation**
- Maintain backward compatibility with Socket.IO for non-message events (metadata/agentState updates, RPC, keep-alive)
## Progress Tracking
- Mark completed items with `[x]` immediately when done
- Add newly discovered tasks with prefix
- Document issues/blockers with ⚠️ prefix
- ⚠️ `npx eslint` in `packages/happy-cli` currently fails because there is no `eslint.config.(js|mjs|cjs)` in this workspace, so lint verification could not be completed.
## Implementation Steps
### Task 1: Migrate message sending to v3 POST via InvalidateSync
- [x] Add `pendingOutbox: Array<{ content: string, localId: string }>` to `ApiSessionClient` — encrypted messages waiting to be sent
- [x] Add `sendSync: InvalidateSync` to `ApiSessionClient` — triggers batch send, created in constructor
- [x] Create private `flushOutbox()` method: drain all pending messages, POST batch to `POST /v3/sessions/:sessionId/messages` via axios
- [x] Create private `enqueueMessage(content: any)` method: encrypt content → base64, generate `localId` (randomUUID), add to outbox, invalidate `sendSync`
- [x] Refactor all 5 send methods to call `enqueueMessage(content)` instead of `socket.emit('message', ...)`
- [x] Remove socket connection checks in send methods (outbox handles disconnection gracefully — messages wait until send succeeds)
- [x] Stop `sendSync` in `close()` method
- [x] Write tests for outbox: single message send, batch accumulation, retry on failure, outbox draining
- [x] Write tests for each send method: verify correct content structure is enqueued
- [x] Run tests — must pass before next task
### Task 2: Add seq tracking and cursor-based message fetch (receive path)
- [x] Add `lastSeq: number` field (initially 0) to `ApiSessionClient`
- [x] Add `receiveSync: InvalidateSync` to `ApiSessionClient` — triggers fetch from v3
- [x] Create private `fetchMessages()` method: GET `/v3/sessions/:sessionId/messages?after_seq=lastSeq&limit=100` via axios, loop while `hasMore`
- [x] In `fetchMessages`: decrypt each message, update `lastSeq` from highest seq in response
- [x] In `fetchMessages`: filter for `UserMessage` (role === 'user') and forward to `pendingMessageCallback` or buffer in `pendingMessages` — same as current behavior
- [x] Emit non-user messages as `'message'` event — same as current behavior
- [x] Write tests for fetchMessages: initial load (after_seq=0), incremental fetch, pagination with hasMore
- [x] Write tests for message routing: user messages → callback, other messages → event emitter
- [x] Run tests — must pass before next task
### Task 3: Update event handler for consecutive seq check (fast path)
- [x] In the `socket.on('update', ...)` handler for `new-message`: read `data.body.message.seq` from the update
- [x] Compare with `this.lastSeq` — if `seq === lastSeq + 1`, decrypt and apply directly (fast path), update lastSeq
- [x] If seq is not consecutive (gap detected), skip direct apply and invalidate `receiveSync` to trigger server fetch
- [x] If `lastSeq === 0` (no messages fetched yet), invalidate `receiveSync`
- [x] Keep existing `update-session` and `update-machine` handlers unchanged (they don't use messages API)
- [x] Write tests for fast-path: consecutive seq applies directly, gap triggers invalidate
- [x] Write tests for edge cases: first message (lastSeq=0), duplicate seq, stale seq
- [x] Run tests — must pass before next task
### Task 4: Update flushOutbox to track sent seq
- [x] In `flushOutbox()`, after successful POST, update `lastSeq` from the highest seq in the response
- [x] This ensures the CLI's own sent messages advance the seq cursor, so subsequent fetches don't re-fetch them
- [x] Write tests for seq advancement after send
- [x] Run tests — must pass before next task
### Task 5: Clean up and verify
- [x] Remove old `socket.emit('message', ...)` calls (all replaced by enqueueMessage)
- [x] Verify `flush()` method still works (it pings the socket, unrelated to message sending — may need to also await sendSync drain)
- [x] Verify `close()` properly stops sendSync and receiveSync
- [x] Verify reconnection behavior: on socket reconnect, receiveSync can be invalidated to catch up
- [x] Verify keepAlive, sendSessionDeath, sendUsageData, updateMetadata, updateAgentState still use Socket.IO (not migrated)
- [x] Run full test suite
- [ ] Run linter — all issues must be fixed
## Technical Details
### Message Outbox (Send)
```
pendingOutbox: Array<{ content: string, localId: string }>
enqueueMessage(rawContent):
1. encrypted = encodeBase64(encrypt(key, variant, rawContent))
2. localId = randomUUID()
3. pendingOutbox.push({ content: encrypted, localId })
4. sendSync.invalidate()
flushOutbox():
1. batch = [...pendingOutbox] // snapshot
2. POST /v3/sessions/:sessionId/messages { messages: batch }
3. On success: clear sent items from outbox, update lastSeq from response
4. On failure: throw → InvalidateSync retries with backoff, messages stay in outbox
```
### Message Fetch (Receive)
```
fetchMessages():
1. Loop:
a. GET /v3/sessions/:sessionId/messages?after_seq=lastSeq&limit=100
b. For each message: decrypt, route (user → callback, other → emit)
c. Update lastSeq to max seq from response
d. If !hasMore, break
```
### Fast-Path Decision Flow
```
new-message event arrives with msg.seq:
1. if lastSeq > 0 AND msg.seq === lastSeq + 1:
→ decrypt + route directly, set lastSeq = msg.seq (fast path)
2. else:
→ receiveSync.invalidate() → triggers fetchMessages
```
### What Stays on Socket.IO
- `session-alive` (keepAlive) — volatile, ephemeral
- `session-end` (sendSessionDeath) — lifecycle
- `usage-report` (sendUsageData) — analytics
- `update-metadata` / `update-state` — uses emitWithAck for optimistic concurrency
- `rpc-request` / `rpc-call` — bidirectional RPC
- `update` events for `update-session` / `update-machine` — metadata/state updates
- `ping` (flush) — connection check
## Post-Completion
**Future improvements:**
- Replace polling-based catch-up with SSE (`GET /v3/sessions/:id/messages/stream`)
- Remove Socket.IO dependency for messages entirely once SSE is in place
- Persist outbox to disk for crash recovery (messages survive CLI restart)
+84
View File
@@ -0,0 +1,84 @@
# Codex: app-server integration
## How we run Codex
Codex is a **system-wide CLI** (`npm install -g @openai/codex`). We don't bundle it.
At startup, `CodexAppServerClient` spawns `codex app-server --listen stdio://` as a child process and talks JSON-RPC 2.0 over stdin/stdout (newline-delimited JSON). The Codex process manages its own model inference, sandbox, and tool execution. We just send prompts and react to events.
Version check: `codex --version` must report >= 0.100 for app-server support.
## Why app-server (not MCP)
The old `codex mcp-server` integration had three unfixable problems:
1. **Model change = context loss.** `codex-reply` only accepts `{ prompt, threadId }`. No model param. Changing model meant restarting the session.
2. **Permission cancel hangs forever.** MCP SDK's `callTool` waits for a response that never comes after `turn_aborted`. Our AbortController workaround was brittle.
3. **Session ID confusion.** Three different ID fields (`sessionId`, `conversationId`, `threadId`) — only `threadId` worked, and it was undocumented.
`codex app-server` solves all three: per-turn model/policy overrides, clean `turn/interrupt` RPC, single `threadId`.
## Architecture
```
Mobile App → Happy Server → CLI (runCodex.ts) → CodexAppServerClient → codex app-server (child process)
↕ JSON-RPC 2.0 over stdio
Events ← codex/event/* notifications
Approvals ← item/commandExecution/requestApproval (server→client RPC)
```
The client has three responsibilities:
- **Lifecycle**: `initialize` handshake → `thread/start``turn/start` per message → `turn/interrupt` on abort
- **Events**: Route `codex/event/*` notifications to the event handler (same EventMsg types as old MCP)
- **Approvals**: Respond to server→client RPC requests for command/patch approval
## Key protocol findings (learned the hard way)
These aren't in any docs. Discovered by trial and error:
| What | Expected | Actual |
|------|----------|--------|
| Thread ID location | `result.conversationId` | `result.thread.id` |
| Turn params | `conversationId`, `items` | `threadId`, `input` |
| Input item format | `{ type: "text", data: { text } }` | `{ type: "text", text }` (flat) |
| Sandbox policy | `"read-only"`, `"workspace-write"` | `{ type: "readOnly" }`, `{ type: "workspaceWrite" }` (camelCase objects) |
| Approval method | `execCommandApproval` | `item/commandExecution/requestApproval` |
| Approval decisions | `approved`, `denied`, `abort` | `accept`, `decline`, `cancel` (wire format differs from internal) |
| Event routing | `codex/event` with type in params | `codex/event/<type>` (type in method name) |
| Empty model string | Ignored | Error: "model '' not supported" (must omit, not send empty) |
## Design decisions
### Per-turn overrides (no restart needed)
Each `turn/start` RPC accepts optional `model`, `approvalPolicy`, `sandboxPolicy`. The thread keeps context across policy changes. This eliminated the mode-change restart block and `experimental_resume` dead code.
### Turn completion tracking
`sendTurnAndWait()` creates a Promise resolved when `task_complete` or `turn_aborted` arrives. Safety nets: 10-minute timeout, process exit handler, disconnect handler. This replaced the AbortController hack.
### Duplicate tool call fix
The old mapper generated `tool-call-start` for both `exec_approval_request` AND `exec_command_begin`. Since the permission handler already renders approval UI via agent state, this created duplicate cards. Fix: only `exec_command_begin` generates `tool-call-start`.
### Approval translation layer
Our internal types use `approved`/`denied`/`abort`. The wire protocol uses `accept`/`decline`/`cancel`. `mapDecisionToWire()` translates between them so the rest of the codebase doesn't need to know about wire format.
## Files
- `codexAppServerClient.ts` — JSON-RPC client, turn tracking, approval handling
- `codexAppServerTypes.ts` — Cherry-picked types from the protocol
- `runCodex.ts` — Main loop, event/approval handler wiring
- `executionPolicy.ts` — Maps permission modes to approval/sandbox policies
- `sessionProtocolMapper.ts` — Events → session protocol envelopes (shared with old code)
## What we don't handle yet
The app-server sends ~60 event types we ignore. Notable ones for future:
- `collab_*` — multi-agent collaboration events
- `web_search_*` — web search tool results
- `planning_*` — planning mode events
- `streaming_content_delta` — finer-grained streaming
- `mcp_*` — MCP server lifecycle (we do use `mcp_startup_complete`)
## References
- [Codex app-server README](https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md)
- [experimental_resume broken — issue #4393](https://github.com/openai/codex/issues/4393)
+282
View File
@@ -0,0 +1,282 @@
# ElevenLabs Voice Usage Gating
## Problem
We want to require a subscription after a user has consumed 1 hour of ElevenLabs conversation time.
For the first version, the constraints are:
- no local usage DB
- no post-call webhook ingestion
- no mid-call cutoff
- first page of ElevenLabs conversation history is acceptable
That means the gate runs only when a new voice session starts. If a user crosses the threshold during an active call, that call continues and the block applies on the next session start.
## Current Repo State
The current `main` implementation does not reliably gate voice usage:
- With `experiments=false`, the app starts ElevenLabs directly with `agentId` and bypasses billing/auth entirely.
- With `experiments=true`, the app calls `/v1/voice/token`, but the app no longer sends `revenueCatPublicKey`.
- The server still expects `revenueCatPublicKey` in production.
- The client treats `400` from `/v1/voice/token` as `allowed:true`, which bypasses the paywall path.
Relevant files:
- Server token route: `packages/happy-server/sources/app/api/routes/voiceRoutes.ts`
- Client token fetch: `packages/happy-app/sources/sync/apiVoice.ts`
- Voice start decision: `packages/happy-app/sources/realtime/RealtimeSession.ts`
- ElevenLabs client session start:
- `packages/happy-app/sources/realtime/RealtimeVoiceSession.tsx`
- `packages/happy-app/sources/realtime/RealtimeVoiceSession.web.tsx`
## Existing Secret Assumptions
The repo already assumes ElevenLabs API access exists on the server:
- `packages/happy-server/sources/app/api/routes/voiceRoutes.ts` reads `process.env.ELEVENLABS_API_KEY`.
- `packages/happy-server/deploy/handy.yaml` extracts `/handy-elevenlabs`.
- `docs/deployment.md` documents `ELEVENLABS_API_KEY` as required for `/v1/voice/token` in production.
The app does not currently have an ElevenLabs API secret. Client config only carries public values such as RevenueCat public keys and ElevenLabs agent IDs.
## Decision
Implement a stateless-at-runtime preflight check that uses ElevenLabs as the system of record:
1. Derive a stable pseudonymous ElevenLabs `user_id` from the Happy user ID.
2. Before issuing a conversation token, query ElevenLabs conversation history for that `user_id`.
3. Read only the first page.
4. Sum `call_duration_secs` across the returned conversations.
5. If cumulative duration is below 3600 seconds, allow voice.
6. If cumulative duration is 3600 seconds or above, require an active subscription.
7. If allowed, mint and return an ElevenLabs conversation token.
8. Start the ElevenLabs session using the same stable `user_id`.
Use a stable pseudonymous ID, not a random nonce. Recommended shape:
`elevenUserId = "u_" + base64url(HMAC_SHA256(APP_SECRET, happyUserId))`
This keeps the join key stable across sessions without exposing the raw Happy account ID to ElevenLabs.
## External APIs
As of 2026-03-24, the relevant ElevenLabs APIs are:
- SDK session start supports passing `userId`
- https://elevenlabs.io/docs/conversational-ai/libraries/react
- Low-level personalization payload supports `user_id`
- https://elevenlabs.io/docs/eleven-agents/customization/personalization
- Conversation history can be listed with `user_id`, and responses include `call_duration_secs`
- https://elevenlabs.io/docs/eleven-agents/api-reference/conversations/list
- ElevenLabs API authentication uses `xi-api-key`
- https://elevenlabs.io/docs/api-reference/authentication
No new ElevenLabs credential is needed beyond the existing server-side `ELEVENLABS_API_KEY`.
## Proposed Control Flow
```text
User taps mic
|
v
startRealtimeSession(sessionId, initialContext)
|
+--> request microphone permission
|
+--> load JWT credentials
|
+--> determine agentId from app config
|
v
POST /v1/voice/token
Authorization: Bearer <jwt>
body: { sessionId, agentId }
|
v
Server authenticates JWT
|
+--> request.userId = Happy account id
|
+--> derive stable elevenUserId from request.userId
|
+--> load ELEVENLABS_API_KEY from env
|
+--> GET /v1/convai/conversations?user_id=<elevenUserId>&page_size=100&summary_mode=exclude
| header: xi-api-key: ELEVENLABS_API_KEY
|
+--> sum conversations[*].call_duration_secs on first page only
|
+--> totalSeconds < 3600 ?
| |
| +--> yes: allow
| |
| +--> no:
| check subscription entitlement
| |
| +--> active subscription: allow
| |
| +--> no subscription:
| return {
| allowed: false,
| reason: "voice_limit_reached",
| usedSeconds: totalSeconds,
| limitSeconds: 3600
| }
|
+--> if allowed:
GET /v1/convai/conversation/token?agent_id=<agentId>
header: xi-api-key: ELEVENLABS_API_KEY
return {
allowed: true,
token,
agentId,
elevenUserId,
usedSeconds: totalSeconds
}
|
v
Client receives response
|
+--> allowed = false
| |
| +--> present paywall
| +--> if purchased/restored: sync purchases and retry
| +--> if cancelled: do not start voice
|
+--> allowed = true
|
+--> start ElevenLabs session with:
- conversationToken
- userId = elevenUserId
- dynamicVariables.sessionId
- dynamicVariables.initialConversationContext
|
v
ElevenLabs records the conversation under user_id = elevenUserId
|
v
Next mic tap repeats the same preflight check
```
## Important Limitations
- First page only is not exact lifetime accounting. It is only the sum of the returned page.
- If ElevenLabs has more matching conversations than the first page, this can undercount.
- If ElevenLabs changes or does not document the exact sort order of the list endpoint, relying on the first page is inherently approximate.
- Because the gate runs only at session start, users can exceed 1 hour during an active conversation.
- No local state means no reconciliation, no idempotency, and no protection against concurrent starts beyond what ElevenLabs history already reflects.
## Required Code Changes
### Server
Update `packages/happy-server/sources/app/api/routes/voiceRoutes.ts` to:
- derive and return `elevenUserId`
- query ElevenLabs conversation history before minting a token
- sum `call_duration_secs`
- return structured denial when the user is over the free threshold and unsubscribed
- stop relying on client-supplied `revenueCatPublicKey`
- perform subscription verification server-side if paywall remains part of the product
Preferred response shape:
```ts
type VoiceTokenResponse =
| {
allowed: true;
token: string;
agentId: string;
elevenUserId: string;
usedSeconds: number;
limitSeconds: number;
}
| {
allowed: false;
reason: 'voice_limit_reached' | 'subscription_required';
usedSeconds: number;
limitSeconds: number;
agentId: string;
};
```
### Client
Update:
- `packages/happy-app/sources/realtime/types.ts`
- `packages/happy-app/sources/realtime/RealtimeVoiceSession.tsx`
- `packages/happy-app/sources/realtime/RealtimeVoiceSession.web.tsx`
- `packages/happy-app/sources/realtime/RealtimeSession.ts`
- `packages/happy-app/sources/sync/apiVoice.ts`
Changes needed:
- add `userId?: string` to `VoiceSessionConfig`
- pass `userId` into `conversationInstance.startSession(...)`
- remove the `400 => allowed:true` fallback
- remove or redesign the `experiments=false` bypass if voice gating should apply to all users
- retry the token request after successful purchase
## Subscription Check
If the product still wants a paywall after the free threshold, the subscription check should be server-side.
Current `main` is mismatched:
- the server expects `revenueCatPublicKey` from the client
- the client no longer sends it
- the deployment already extracts `/handy-revenuecat`
Preferred fix:
- use a server-side RevenueCat credential or another trusted subscription source
- keep RevenueCat public keys only for rendering the client paywall
- treat the client purchase result as a hint, then verify entitlement on the server before issuing a token
## Testing
### Server tests
Add route tests for:
- no prior ElevenLabs conversations
- first page total below threshold
- first page total exactly at threshold
- first page total above threshold with no subscription
- first page total above threshold with active subscription
- missing `ELEVENLABS_API_KEY`
- ElevenLabs history API failure
- ElevenLabs token API failure
### Client tests
Add tests for:
- allowed response with token
- denied response presents paywall
- successful purchase retries the request
- cancelled purchase does not start voice
- `userId` is threaded into `startSession(...)`
- voice gating still happens when experimental settings are disabled, if that is the desired product behavior
### Manual verification
1. Run against a production-like server with `ELEVENLABS_API_KEY` configured.
2. Use a stable test account so the derived `elevenUserId` is consistent across runs.
3. Seed the account with enough ElevenLabs conversation duration to land below and above 3600 seconds.
4. Verify:
- below threshold: voice starts
- above threshold + no subscription: paywall appears and voice does not start
- above threshold + active subscription: voice starts
5. Confirm ElevenLabs sessions are created with the expected `user_id`.
## Prior Art
There is older server-only free-trial work in the legacy `slopus/happy-server` repository on branch:
- `charge-for-voice-after-3-trail-conversations`
That branch tracked free trials using database counters, not ElevenLabs duration history. It is useful as prior art for gating shape and server-side entitlement checks, but it does not implement the 1-hour cumulative duration design described here.
@@ -0,0 +1,80 @@
# Experimental Chat File Links
## Problem
Codex is emitting local file references as absolute-path markdown links because the upstream prompt bundle says:
- `For clickable/openable file references, the path target must be an absolute filesystem path.`
- `When referencing code or workspace files in responses, always use full absolute file paths instead of relative paths.`
Happy does not currently implement that contract. The chat renderer only handles:
- explicit markdown links
- bare `http(s)` URLs
That creates two broken cases:
1. Absolute markdown file links are treated as ordinary browser links on web, so `/Users/.../file.ts:12` becomes `http://localhost/...`.
2. Bare relative file refs like `packages/foo.ts:12` are rendered as plain text because they are never auto-linkified.
## Goals
- Gate the entire feature behind the existing `experiments` setting.
- Support both absolute and relative file refs in chat.
- Route file refs into the internal file viewer instead of the browser.
- Canonicalize paths before calling the RPC layer.
- Keep external links working exactly as they do today.
## Control Flow
1. Parse markdown as today.
2. When rendering spans:
- If `experiments` is off, keep existing behavior.
- If a span is inline code, do not auto-link file refs.
- If a span already has a URL, try to parse it as a file ref first.
- If a span is plain text, scan whitespace-delimited tokens for file refs.
3. If a candidate parses as a file ref:
- Resolve it to one canonical absolute path.
- Push `/session/:id/file?path=<base64(abs)>&line=<n>&column=<n>`.
- Do not expose it as a browser `href`.
4. In the file viewer:
- Decode the incoming path.
- Resolve relative input against the session root.
- Normalize to a canonical absolute path.
- Read the file through RPC using the canonical absolute path.
- If the file is inside the session root, compute a repo-relative path for `git diff`.
- If the file is outside the session root, skip diff and show the file directly.
5. In the RPC layer:
- Resolve the incoming path to a canonical absolute path.
- Validate access using the canonical absolute path.
- Read/write/list using the canonical absolute path, not the raw input.
## Supported Cases
- Explicit markdown absolute links like `[foo.ts:12](/Users/me/repo/foo.ts:12)`
- Explicit markdown relative links like `[foo.ts:12](packages/app/foo.ts:12)`
- Bare absolute refs like `/Users/me/repo/foo.ts:12`
- Bare relative refs like `packages/app/foo.ts:12`
- Optional `:line`
- Optional `:line:column`
- Windows drive paths like `C:\repo\foo.ts:12`
## Rejected Cases
- External URLs like `https://...`
- URI schemes like `mailto:` and `node:`
- Plain prose tokens that do not look file-like
- Inline code spans and fenced code blocks
## Viewer Semantics
- The route accepts either absolute or relative `path` values for backwards compatibility.
- Internally, the viewer normalizes to a canonical absolute path.
- Diff view is shown only when the file is inside the session root and `git diff` returns content.
- A `line` query forces file view and scrolls near the requested line.
## RPC Semantics
- `validatePath()` should return the resolved canonical path.
- Callers should use `resolvedPath` for filesystem operations.
- This keeps the app and CLI aligned on one path representation.
+56
View File
@@ -0,0 +1,56 @@
# Expo SDK 55 Upgrade
## Status: Next native submission
Current: SDK 54 / RN 0.81.4 / React 19.1. Last SDK 54 builds submitted 2026-03-22.
Target: SDK 55 / RN 0.83 / React 19.2.
RN 0.81 is out of the 3-version support window (0.82, 0.83, 0.84 supported). SDK 54 is "previous" — SDK 55 is current stable since Feb 2026.
## What we're excited about
- **react-native-keyboard-controller 1.21** — `KeyboardChatScrollView`, cross-platform `contentInset`. 400+ likes on Twitter. Direct win for our chat UI.
- **Expo Router v7** — Liquid Glass tab bar, Apple Zoom transitions, declarative headers, Stack.Toolbar API
- **75% smaller OTA updates** — Hermes bytecode diffing. Huge for our frequent preview OTAs.
- **React 19.2** — `<Activity>` component (keep off-screen components alive), `useEffectEvent`
## Other notable changes in SDK 55
- New Architecture is now **mandatory** (Legacy Arch removed, `newArchEnabled` flag gone)
- Unified package versioning (all expo-* packages = 55.x.x)
- expo-blur uses RenderNode API on Android 12+ (cheaper blurs)
- expo-widgets — iOS home screen widgets without native code
- Jetpack Compose beta — Material3 components
- expo-brownfield package for mixed-codebase apps
- expo-av removed from Expo Go (we use expo-audio, so fine)
## Upgrade order
1. Safe patches first (reanimated 4.2.3, livekit, socket.io, zustand) — no native rebuild
2. JS-only minors (flash-list 2.3, posthog, purchases) — no native rebuild
3. **Expo SDK 54 -> 55** via `npx expo install --fix` — native rebuild required
4. keyboard-controller 1.21, skia 2.5.3, unistyles 3.1.1, gesture-handler 2.30
5. Major migrations last: zod 4, react-native-mmkv 4 (independent, breaking API changes)
## Watch out for
- **react-native-screens v4.24** has iOS build issues (`undeclared identifier RNSBottomTabsScreenComponentView`). Pin to tested version.
- **expo-notifications** still buggy — headless background tasks fail when terminated (both platforms), Android channels ignored in background. No fix in SDK 55.
- **expo-blur** now requires `<BlurTargetView>` wrapper — breaking change
- **@expo/ui renames** — DateTimePicker->DatePicker, Switch->Toggle, CircularProgress->ProgressView
- **Hermes v1 opt-in** available but forces build-from-source (slower builds). Wait for SDK 56 / RN 0.84 where prebuilt binaries + Hermes v1 work together.
- **vision-camera** — slow maintenance, pin 4.7.3
## Version snapshot (current -> target)
| Package | Current | Target |
|---|---|---|
| expo | 54.0.0 | 55.0.8 |
| react-native | 0.81.4 | 0.83.1 |
| react | 19.1.0 | 19.2.4 |
| keyboard-controller | 1.18.5 | 1.21.1 |
| flash-list | 2.0.2 | 2.3.0 |
| react-native-skia | 2.2.12 | 2.5.3 |
| react-native-screens | 4.16.0 | 4.22+ (avoid 4.24) |
| purchases | 9.4.2 | 9.14.0 |
| posthog | 4.16.2 | 4.37.5 |
+252
View File
@@ -0,0 +1,252 @@
# Generic ACP Runner
## Overview
Create a clean, generic ACP agent runner that starts any ACP-compatible CLI from a command + args and communicates via ACP protocol. The runner maps ACP events to the new session protocol (envelopes) through a stateful handler class. No vendor-specific hacks. No credentials/env/API key resolution. No session restarts. No conversation history. Just: command in, session protocol out.
This enables support for Gemini, OpenCode, and any future ACP agent without writing per-agent runners.
## Context
- **Existing AcpBackend** (`agent/acp/AcpBackend.ts`) already handles process spawning, ACP JSON-RPC, permissions, and tool tracking. Reused as-is.
- **Existing runGemini.ts** (~1300 lines) is vendor-specific. Stays untouched; migration happens later.
- **Codex already uses new session protocol** via `mapCodexMcpMessageToSessionEnvelopes()` — reference pattern.
- **Session protocol types** in `@slopus/happy-wire` (`SessionEnvelope`, `createEnvelope()`).
- **AgentMessage** is the event type emitted by `AcpBackend.onMessage()`.
- The runner does NOT resolve credentials, API keys, OAuth tokens, or environment variables. The user's shell environment is inherited as-is. If `gemini` needs `GEMINI_API_KEY`, the user sets it before running.
## Development Approach
- **Testing approach**: Regular (code first, then tests)
- Complete each task fully before moving to the next
- **CRITICAL: every task MUST include new/updated tests**
- **CRITICAL: all tests must pass before starting next task**
## Testing Strategy
- **Unit tests**: Required for every task
- Test the stateful event mapper thoroughly (it's the core logic)
## Progress Tracking
- Mark completed items with `[x]` immediately when done
- Add newly discovered tasks with + prefix
- Document issues/blockers with !! prefix
## Implementation Steps
### Task 1: Create AcpSessionMapper class
The stateful handler that maps `AgentMessage` events from `AcpBackend` into `SessionEnvelope[]` for the new session protocol. This is the core logic.
**File**: `packages/happy-cli/src/agent/acp/AcpSessionMapper.ts`
**Class design**:
```typescript
class AcpSessionMapper {
private currentTurnId: string | null = null;
// Called when agent emits a message. Returns envelopes to send.
mapMessage(msg: AgentMessage): SessionEnvelope[]
}
```
**Mapping rules** (AgentMessage type -> SessionEnvelope events):
- `status: 'running'` (first time per turn) -> `turn-start` envelope (creates new turnId)
- `status: 'idle'` or `status: 'stopped'` (when turn is active) -> `turn-end { status: 'completed' }`
- `status: 'error'` (when turn is active) -> `turn-end { status: 'failed' }`
- `model-output { textDelta }` -> `text { text: textDelta }`
- `tool-call` -> `tool-call-start { call, name, title, description, args }`
- `tool-result` -> `tool-call-end { call }`
- `event { name: 'thinking' }` -> `text { text, thinking: true }`
- `permission-request`, `permission-response`, `token-count` -> ignored (handled elsewhere)
**Key behaviors**:
- Turn lifecycle: `running` starts a turn, `idle/stopped/error` ends it
- No turn nesting — only one turn active at a time
- Idempotent: multiple `running` statuses don't create multiple turns
- Multiple `idle` statuses don't create multiple `turn-end`s
**ID generation**:
- `turn` IDs: generated as cuid2 via `createId()` on `turn-start`
- `call` IDs in `tool-call-start` / `tool-call-end`: generated as cuid2, mapped from the ACP backend's `callId`
- Envelope `id` fields: generated as cuid2 via `createEnvelope()` (automatic)
- `subagent` IDs: cuid2 (future, not needed for v1)
Since all IDs are non-deterministic cuid2, tests must:
- Assert structural shape (correct `ev.t`, correct fields present)
- Assert ID consistency (same `turn` across all envelopes in a turn, same `call` in start/end pairs)
- Assert ID format (valid cuid2)
- NOT assert exact ID values
**Test file**: `packages/happy-cli/src/agent/acp/AcpSessionMapper.test.ts`
**Test cases for turn lifecycle**:
- `running` -> emits 1 envelope: `turn-start` with cuid2 `turn`
- `running` then `idle` -> emits `turn-start` then `turn-end { status: 'completed' }`, both share same `turn`
- `running` then `error` -> `turn-start` then `turn-end { status: 'failed' }`, same `turn`
- `running` then `stopped` -> `turn-start` then `turn-end { status: 'cancelled' }`, same `turn`
- `running, running` -> only 1 `turn-start` (idempotent)
- `idle` without prior `running` -> no envelopes (no turn to end)
- `idle, idle` -> only 1 `turn-end` (idempotent)
- `running, idle, running, idle` -> 2 complete turn cycles, each with different `turn` cuid2
- `starting` status -> ignored (no envelopes)
**Test cases for text mapping**:
- `model-output { textDelta }` during active turn -> `text { text }` envelope with correct `turn`
- `model-output` without active turn -> `text` envelope without `turn` (or auto-start turn — TBD)
- Empty `textDelta` -> no envelope
**Test cases for tool call mapping**:
- `tool-call { callId, toolName, args }` -> `tool-call-start { call, name, title, description, args }` with cuid2 `call`
- `tool-result { callId }` -> `tool-call-end { call }` where `call` matches the cuid2 mapped from same `callId`
- Multiple tool calls -> each gets distinct cuid2 `call`, correctly paired start/end
- `tool-result` for unknown `callId` -> still emits `tool-call-end` with new cuid2
- All tool envelopes carry the current `turn`
**Test cases for thinking**:
- `event { name: 'thinking', payload: { text } }` -> `text { text, thinking: true }` with current `turn`
- Thinking with empty text -> no envelope
**Test cases for ignored messages**:
- `permission-request` -> no envelopes
- `permission-response` -> no envelopes
- `token-count` -> no envelopes
- `fs-edit` -> no envelopes
- `terminal-output` -> no envelopes
**Test cases for ID consistency across a full sequence**:
- Simulate full turn: `running` -> `model-output` -> `tool-call` -> `tool-result` -> `model-output` -> `idle`
- Assert all envelopes share same `turn` cuid2
- Assert `tool-call-start.call` matches `tool-call-end.call`
- Assert all `id` fields are unique cuid2
- Assert `turn` changes between separate turns
- [ ] Create `AcpSessionMapper` class with `mapMessage()` method
- [ ] Implement turn lifecycle (turn-start on running, turn-end on idle/error)
- [ ] Implement text mapping (model-output -> text event)
- [ ] Implement tool call mapping (tool-call -> tool-call-start, tool-result -> tool-call-end) with cuid2 call ID mapping
- [ ] Implement thinking mapping (event/thinking -> text with thinking: true)
- [ ] Write tests for turn lifecycle (all cases above)
- [ ] Write tests for text/tool/thinking mapping (all cases above)
- [ ] Write tests for ignored messages
- [ ] Write tests for ID consistency across full turn sequence
- [ ] Write tests for edge cases (multiple idles, running without idle, etc.)
- [ ] Run tests - must pass before next task
### Task 2: Create generic runAcp runner function
The runner function that wires everything together: creates AcpBackend, listens for messages, maps them through AcpSessionMapper, and sends them to the session.
**File**: `packages/happy-cli/src/agent/acp/runAcp.ts`
**Signature**:
```typescript
async function runAcp(opts: {
credentials: Credentials;
agentName: string; // e.g. 'gemini', 'opencode'
command: string; // e.g. 'gemini'
args: string[]; // e.g. ['--experimental-acp']
startedBy?: 'daemon' | 'terminal';
}): Promise<void>
```
**No credentials/env resolution** — the command is run with the user's inherited shell environment. No `GEMINI_API_KEY`, no OAuth tokens, no model resolution. The user sets their env before running.
**What it does** (simplified flow):
1. Create API session (same pattern as runGemini but simpler)
2. Start Happy MCP server for tool bridge
3. Create AcpBackend with DefaultTransport (no vendor-specific transport)
4. Create AcpSessionMapper
5. Wire: `backend.onMessage(msg => mapper.mapMessage(msg).forEach(env => session.sendSessionProtocolMessage(env)))`
6. Start ACP session
7. Listen for user messages from session, forward to backend via `sendPrompt()`
8. Handle abort/kill session
9. Simple console logging (no Ink)
10. Clean up on exit
- [ ] Create `runAcp()` function with session setup (API client, machine, session creation)
- [ ] Wire AcpBackend + AcpSessionMapper + session protocol message sending
- [ ] Implement user message handling (session.onUserMessage -> messageQueue -> sendPrompt)
- [ ] Implement abort/kill session handlers
- [ ] Implement permission handling via AcpPermissionHandler interface
- [ ] Add simple console logging for status (no Ink)
- [ ] Add cleanup/dispose logic
- [ ] Write tests for runner setup and message flow (with mocked backend)
- [ ] Run tests - must pass before next task
### Task 3: Register agents and add CLI commands
Wire the generic runner into the CLI so users can run `happy acp gemini` or `happy acp opencode` or `happy acp -- custom-agent --flag`.
**Files**:
- `packages/happy-cli/src/index.ts` — add CLI command routing
- Agent configs for known agents (command + args only, no env/credentials)
**Agent config**:
```typescript
const KNOWN_ACP_AGENTS: Record<string, { command: string; args: string[] }> = {
gemini: { command: 'gemini', args: ['--experimental-acp'] },
opencode: { command: 'opencode', args: ['--acp'] },
};
```
No env vars, no API keys, no model config. Just command + args.
- [ ] Define known ACP agent configs (command + args only)
- [ ] Add CLI routing for `happy acp <agent-name>` and `happy acp -- <cmd> [args]`
- [ ] Wire to `runAcp()` with resolved config
- [ ] Write tests for agent config resolution
- [ ] Run tests - must pass before next task
### Task 4: Verify acceptance criteria
- [ ] Verify generic runner works without vendor-specific code
- [ ] Verify no credentials/env/API key resolution anywhere in the new code
- [ ] Verify new session protocol envelopes are emitted correctly
- [ ] Verify sessions are never restarted (only in-memory state)
- [ ] Verify permission handling works
- [ ] Run full test suite (unit tests)
- [ ] Run linter - all issues must be fixed
### Task 5: [Final] Update documentation
- [ ] Update README.md if needed
- [ ] Add inline comments explaining the architecture
## Technical Details
### AcpSessionMapper State Machine
```
[no turn] ---(status: running)---> [turn active]
[turn active] ---(status: idle)---> [no turn] (emit turn-end: completed)
[turn active] ---(status: error)---> [no turn] (emit turn-end: failed)
[turn active] ---(status: stopped)---> [no turn] (emit turn-end: cancelled)
```
All content events (text, tool-call-start, tool-call-end, thinking) are emitted with the current `turnId` set on the envelope.
### No Credentials / No Env Resolution
The runner inherits the user's shell environment. Period. If the agent needs `GEMINI_API_KEY` or `OPENAI_API_KEY`, the user sets it in their shell. The runner doesn't know or care about vendor-specific env vars.
### No Session Restarts
The ACP protocol supports model switching natively. The process stays alive for the entire CLI session. No dispose-and-recreate. No conversation history injection.
### DefaultTransport
The generic runner uses `DefaultTransport` which:
- 60s init timeout
- Filters non-JSON stdout lines
- No stderr error detection
- No tool name extraction hacks
If a specific agent needs custom transport behavior, it can be added later — but the runner stays generic.
## Post-Completion
**Manual verification:**
- Test with `gemini --experimental-acp` to verify ACP protocol works
- Test with `opencode --acp` when available
- Verify mobile app receives session protocol envelopes correctly
**Future work:**
- Migrate `runGemini.ts` to use generic runner as thin wrapper
- Add agent-specific transport handlers only when proven necessary
+336
View File
@@ -0,0 +1,336 @@
# happy-agent CLI Tool
## Overview
A new standalone CLI tool (`happy-agent`) in `packages/happy-agent` that acts as a dedicated client for controlling Happy Coder agents remotely. Unlike `happy-cli` which both runs and controls agents, `happy-agent` only controls them — listing machines, spawning sessions on a machine, creating sessions, sending messages, reading history, monitoring state, and stopping sessions.
This is a completely separate client from `happy-cli`. It has its own authentication flow (account auth via QR code, same as device linking in the mobile app), its own credential storage (`~/.happy/agent.key`), and is written from scratch with no code sharing.
## Context
- **Existing system**: Monorepo with `happy-cli` (agent runtime + control), `happy-server` (Fastify + PostgreSQL + Redis), `happy-app` (React Native mobile)
- **Server API**: REST endpoints at `https://api.cluster-fluster.com` + Socket.IO at `/v1/updates`
- **Authentication**: Uses account auth flow (`/v1/auth/account/request` + `/v1/auth/account/response`) — generates ephemeral keypair, displays QR code (`happy:///account?[base64url-publicKey]`), user scans with existing Happy mobile app to approve, receives encrypted account secret
- **Credential storage**: `~/.happy/agent.key` (separate from happy-cli's `~/.happy/access.key`)
- **Encryption**: AES-256-GCM (dataKey) for all new sessions. The master content keypair is derived deterministically from the account secret via `deriveKey(secret, 'Happy EnCoder', ['content'])` → seed → `crypto_box_seed_keypair(seed)`. Per-session random keys are encrypted with the master public key and stored on the server.
- **Session protocol**: HTTP POST to create sessions, Socket.IO for real-time messages/state updates
- **Agent state**: `AgentState.controlledByUser` indicates if agent is actively processing; `requests` field tracks pending tool calls
## Development Approach
- **Testing approach**: Regular (code first, then tests)
- Complete each task fully before moving to the next
- Make small, focused changes
- **CRITICAL: every task MUST include new/updated tests** for code changes in that task
- **CRITICAL: all tests must pass before starting next task**
- **CRITICAL: update this plan file when scope changes during implementation**
- Run tests after each change
## Testing Strategy
- **Unit tests**: Required for every task — encryption, key derivation, API client logic, CLI argument parsing, auth flow
- **Integration tests**: Use the real environment bootstrap (`yarn env:up:authenticated`) and exercise the live server + daemon + CLI stack. Do not use mocked acceptance coverage for `happy-agent spawn`.
## Progress Tracking
- Mark completed items with `[x]` immediately when done
- Add newly discovered tasks with prefix
- Document issues/blockers with ⚠️ prefix
## Implementation Steps
### Task 1: Package scaffolding and build setup
- [x] Create `packages/happy-agent/` directory with `package.json` (name: `happy-agent`, type: module, bin: `./bin/happy-agent.mjs`)
- [x] Create `tsconfig.json` with strict mode, path aliases (`@/``src/`), ESM output
- [x] Create `bin/happy-agent.mjs` entry point wrapper (mirrors happy-cli pattern: spawns node with `--no-warnings`)
- [x] Create `src/index.ts` as main entry point with argument parsing shell
- [x] Add package to root `package.json` workspaces
- [x] Add dependencies: `axios`, `socket.io-client`, `tweetnacl`, `zod`, `chalk`, `commander`, `qrcode-terminal`
- [x] Add devDependencies: `typescript`, `vitest`, `pkgroll`, `tsx`
- [x] Create `vitest.config.ts`
- [x] Verify `yarn install` and `yarn build` work
- [x] Write smoke test that imports the package entry point
- [x] Run tests — must pass before task 2
### Task 2: Encryption and key derivation module
- [x] Create `src/encryption.ts` with `encodeBase64`, `decodeBase64`, `encodeBase64Url`, `getRandomBytes` functions
- [x] Implement `hmac_sha512(key, data)` using Node.js `createHmac('sha512', ...)`
- [x] Implement key derivation tree:
- `deriveSecretKeyTreeRoot(seed, usage)` — HMAC-SHA512 with key = `usage + ' Master Seed'` (UTF-8), data = seed. Split 64-byte result: key = `[0:32]`, chainCode = `[32:64]`
- `deriveSecretKeyTreeChild(chainCode, index)` — HMAC-SHA512 with key = chainCode, data = `[0x00, ...UTF-8(index)]`. Split same way.
- `deriveKey(master, usage, path)` — derives root, then iterates path elements through child derivation
- `deriveContentKeyPair(secret)` — calls `deriveKey(secret, 'Happy EnCoder', ['content'])` → seed → `sha512(seed)[0:32]``tweetnacl.box.keyPair.fromSecretKey()` → returns `{ publicKey, secretKey }`
- [x] Implement AES-256-GCM encryption:
- `encryptWithDataKey(data, dataKey)` — AES-256-GCM: `[1-byte version=0][12-byte nonce][ciphertext][16-byte auth tag]`
- `decryptWithDataKey(bundle, dataKey)` — reverse of above
- [x] Implement legacy encryption (needed for backward compatibility with existing sessions):
- `encryptLegacy(data, secret)` — TweetNaCl secretbox: `[24-byte nonce][ciphertext + MAC]`
- `decryptLegacy(data, secret)` — reverse of above
- [x] Implement `encrypt(key, variant, data)` / `decrypt(key, variant, data)` dispatcher for `'legacy' | 'dataKey'` variants
- [x] Implement `libsodiumEncryptForPublicKey(data, recipientPublicKey)` — encrypts data with NaCl box using ephemeral keypair. Bundle: `[32-byte ephemeral pubkey][24-byte nonce][ciphertext]`
- [x] Implement `decryptBoxBundle(bundle, recipientSecretKey)` — decrypts NaCl box bundle (used for auth response decryption AND per-session key decryption)
- [x] Implement `authChallenge(secret)` — generates signing keypair from secret seed, creates random 32-byte challenge, signs with `tweetnacl.sign.detached`. Returns `{ challenge, publicKey, signature }` for token refresh via `/v1/auth`
- [x] Write tests for key derivation with known test vectors:
- seed=`'test seed'`, usage=`'test usage'`, path=`['child1','child2']`
- Expected root key: `E6E55652456F9FE47D6FF46CA3614E85B499F77E7B340FBBB1553307CEDC1E74`
- Expected final key: `1011C097D2105D27362B987A631496BBF68B836124D1D072E9D1613C6028CF75`
- [x] Write tests for AES-256-GCM encrypt/decrypt round-trip
- [x] Write tests for legacy encrypt/decrypt round-trip
- [x] Write tests for base64 encode/decode (standard and URL-safe)
- [x] Write tests for libsodiumEncryptForPublicKey + decryptBoxBundle round-trip
- [x] Write tests for authChallenge signature verification with `tweetnacl.sign.detached.verify`
- [x] Run tests — must pass before task 3
### Task 3: Configuration and credential storage
- [x] Create `src/config.ts` — reads `HAPPY_SERVER_URL` (default: `https://api.cluster-fluster.com`), `HAPPY_HOME_DIR` (default: `~/.happy`), derives credential file path as `${happyHomeDir}/agent.key`
- [x] Create `src/credentials.ts`:
- `Credentials` type: `{ token: string, secret: Uint8Array, contentKeyPair: { publicKey: Uint8Array, secretKey: Uint8Array } }`
- `readCredentials(config)` — parses `~/.happy/agent.key` JSON `{ token, secret }`, decodes secret from base64, derives contentKeyPair via `deriveContentKeyPair(secret)`. Returns `Credentials` or `null` if file missing.
- `writeCredentials(config, token, secret)` — writes `{ token, secret: base64(secret) }` to `~/.happy/agent.key`
- `clearCredentials(config)` — deletes `~/.happy/agent.key`
- `requireCredentials(config)` — calls `readCredentials`, throws with "Run `happy-agent auth login` first" if null
- [x] Write tests for credential read/write round-trip (use temp directory)
- [x] Write tests for contentKeyPair derivation from secret
- [x] Write tests for missing file returns null
- [x] Write tests for config defaults and env var overrides
- [x] Run tests — must pass before task 4
### Task 4: Authentication command (`happy-agent auth`)
- [x] Create `src/auth.ts` implementing the account auth flow:
1. Generate ephemeral box keypair: `tweetnacl.box.keyPair.fromSecretKey(randomBytes(32))`
2. POST `/v1/auth/account/request` with `{ publicKey: base64(keypair.publicKey) }`
3. Generate QR code data: `happy:///account?` + base64url(keypair.publicKey)
4. Display QR code in terminal using `qrcode-terminal`
5. Print instructions: "Scan this QR code with the Happy app (Settings → Account → Link New Device)"
6. Poll `/v1/auth/account/request` every 1 second with same publicKey
7. When `state === 'authorized'`: decrypt `response` using `decryptBoxBundle(decodeBase64(response), keypair.secretKey)` to get the account secret (32 bytes)
8. Save token + secret via `writeCredentials(config, token, secret)`
9. Print success message
- [x] Add `happy-agent auth login` subcommand that runs the flow above
- [x] Add `happy-agent auth logout` subcommand that calls `clearCredentials()`
- [x] Add `happy-agent auth status` subcommand that reads credentials and prints auth status (authenticated / not authenticated)
- [x] Write tests for auth flow with mocked HTTP (polling, success case)
- [x] Write tests for auth flow error cases (server unreachable, timeout)
- [x] Write tests for logout (credential deletion)
- [x] Run tests — must pass before task 5
### Task 5: HTTP API client
- [x] Create `src/api.ts` with functions:
- `listSessions(config, creds)` — GET `/v1/sessions`, for each session: resolve encryption key (see key resolution below), decrypt metadata/agentState, return decrypted session list
- `listActiveSessions(config, creds)` — GET `/v2/sessions/active`, same decryption logic
- `createSession(config, creds, opts: { tag, metadata })` — POST `/v1/sessions`:
- Generate random 32-byte per-session AES key
- Encrypt it with `libsodiumEncryptForPublicKey(sessionKey, creds.contentKeyPair.publicKey)` → prepend version byte `[0x00]` → base64 for `dataEncryptionKey` field
- Encrypt metadata with `encryptWithDataKey(metadata, sessionKey)`
- Returns decrypted session with the sessionKey attached
- `getSessionMessages(config, creds, sessionId)` — GET `/v1/sessions/:id/messages`
- `deleteSession(config, creds, sessionId)` — DELETE `/v1/sessions/:id`
- [x] Implement session encryption key resolution for existing sessions:
- If session has `dataEncryptionKey`: strip version byte, `decryptBoxBundle(encrypted, creds.contentKeyPair.secretKey)` → per-session AES key, use `'dataKey'` variant
- If session has no `dataEncryptionKey`: use `creds.secret` as key with `'legacy'` variant
- [x] All requests include `Authorization: Bearer <token>` header
- [x] All functions handle HTTP errors gracefully (404 → "not found", 401 → "re-authenticate", 5xx → "server error")
- [x] Write tests with mocked axios for listSessions (success + error)
- [x] Write tests for session key resolution (dataKey and legacy paths)
- [x] Write tests with mocked axios for createSession (new + existing tag)
- [x] Write tests with mocked axios for getSessionMessages
- [x] Write tests with mocked axios for deleteSession
- [x] Run tests — must pass before task 6
### Task 6: Socket.IO session client
- [x] Create `src/session.ts``SessionClient` class that:
- Takes session ID, encryption key, encryption variant, token, server URL
- Connects to Socket.IO at `serverUrl/v1/updates` with `{ token, clientType: 'session-scoped', sessionId }`
- Listens for `update` events, decrypts messages using session encryption key (AES-256-GCM or legacy depending on variant), emits typed events (`message`, `state-change`)
- Provides `sendMessage(text, meta?)` — encrypts user message with session key and emits `message` event with `{ sid, message }`
- Provides `getMetadata()` / `getAgentState()` — returns current cached decrypted state
- Provides `waitForIdle(timeoutMs?)` — watches `agentState.controlledByUser` and `agentState.requests`, resolves when agent has no pending requests and `controlledByUser !== true`
- Provides `sendStop()` — emits `session-end` event
- Provides `close()` — disconnects socket
- [x] Write tests for SessionClient message encryption/sending (mock socket.io-client)
- [x] Write tests for waitForIdle logic (various agentState combinations)
- [x] Write tests for update event handling and decryption
- [x] Run tests — must pass before task 7
### Task 7: CLI commands — `list` and `status`
- [x] Create `src/index.ts` using `commander` with program name `happy-agent`
- [x] `happy-agent list` — calls `listSessions`, displays table: ID (truncated), name/summary, path, status (active/inactive), last active time. With `--json` outputs raw JSON. With `--active` filters to active only.
- [x] `happy-agent status <session-id>` — fetches session via list + filter by ID prefix, connects Socket.IO to get live state, displays: session ID, metadata (path, host, lifecycle state), agent state (idle/busy, pending requests count), last message preview. With `--json` outputs raw JSON. Disconnects after displaying.
- [x] Create `src/output.ts` — helper for human-readable vs JSON formatting based on `--json` flag
- [x] Write tests for output formatting (human-readable table, JSON mode)
- [x] Write tests for CLI argument parsing (list, list --active, list --json, status <id>)
- [x] Run tests — must pass before task 8
### Task 8: CLI commands — `create` and `send`
- [x] `happy-agent create --tag <tag> [--path <path>]` — creates new session with given tag and metadata (path defaults to cwd, host to hostname). Prints session ID. With `--json` outputs full session JSON.
- [x] `happy-agent send <session-id> <message>` — resolves session key, connects Socket.IO, sends user message (encrypted with AES-256-GCM), optionally waits for idle with `--wait`, and supports `--yolo` to send `permissionMode=yolo`. Disconnects after. Prints confirmation. With `--json` outputs message details.
- [x] Write tests for create command (argument parsing, metadata construction)
- [x] Write tests for send command (message encryption, --wait flag)
- [x] Run tests — must pass before task 9
### Task 9: CLI commands — `history`, `stop`, and `wait`
- [x] `happy-agent history <session-id>` — fetches messages via HTTP, resolves session encryption key (dataKey or legacy), decrypts each message, displays in chronological order with role/timestamp. With `--json` outputs raw JSON. With `--limit <n>` limits output.
- [x] `happy-agent stop <session-id>` — connects Socket.IO, sends `session-end` event, disconnects. Prints confirmation.
- [x] `happy-agent wait <session-id> [--timeout <seconds>]` — connects Socket.IO, waits for agent idle state (no pending requests, not controlled by user), prints when idle or times out (default 300s). Exit code 0 on idle, 1 on timeout.
- [x] Write tests for history command (message decryption, chronological ordering, --limit)
- [x] Write tests for stop command
- [x] Write tests for wait command (idle detection, timeout handling)
- [x] Run tests — must pass before task 10
### Task 10: Verify acceptance criteria
- [x] Verify the session control operations work: auth, create, send, stop, history, wait, status, list
- [x] Verify `--json` flag works on all applicable commands
- [x] Verify error handling: no credentials, server unreachable, invalid session ID
- [x] Verify interop: session created by happy-agent is visible and controllable from mobile app
- [x] Verify interop: session created by happy-cli can be listed and history read by happy-agent
- [x] Run full test suite (unit tests)
- [x] Run linter — all issues must be fixed
### Task 11: [Final] Update documentation
- [x] Add README.md to `packages/happy-agent/` with usage examples for all commands
- [x] Update root README if it references packages
### Task 12: Machines and spawn
- [x] Add `happy-agent machines [--active] [--json]`
- [x] Add machine record decryption using the existing account content key derivation and record data key pattern
- [x] Add `happy-agent spawn --machine <machine-id> [--path <path>] [--agent <agent>] [--create-dir] [--json]`
- [x] Reuse the existing machine RPC contract (`spawn-happy-session`) without encryption shortcuts
- [x] Add a real integration test that boots `yarn env:up:authenticated`, authenticates `happy-agent`, lists machines, spawns via the real daemon RPC path, and verifies the live session
## Technical Details
### CLI Commands Summary
```
happy-agent auth login # Authenticate via QR code (scanned by Happy mobile app)
happy-agent auth logout # Clear stored credentials
happy-agent auth status # Show authentication status
happy-agent machines [--active] [--json] # List machines
happy-agent list [--active] [--json] # List all sessions
happy-agent spawn --machine <machine-id> [--path <path>] [--agent <agent>] [--create-dir] [--json] # Spawn on a machine
happy-agent status <session-id> [--json] # Get live session state
happy-agent create --tag <tag> [--path <path>] [--json] # Create new session
happy-agent send <session-id> <message> [--yolo] [--wait] [--json] # Send message
happy-agent history <session-id> [--limit <n>] [--json] # Read message history
happy-agent stop <session-id> # Stop a session
happy-agent wait <session-id> [--timeout <s>] # Wait for agent to become idle
```
### Authentication Flow (Account Auth)
```
happy-agent Happy Server Happy Mobile App
| | |
+-- Generate ephemeral keypair | |
+-- POST /v1/auth/account/request -> | |
| { publicKey } | |
| | |
+-- Display QR code in terminal | |
| happy:///account?[base64url-key] | |
| | |
| | <-- User scans QR code ------+
| | |
| | <-- POST /v1/auth/account/response
| | { publicKey, |
| | response: box.encrypt( |
| | accountSecret, |
| | ephemeralPubKey) } |
| | |
+-- Poll /v1/auth/account/request -> | |
| state: 'authorized' | |
| token: JWT | |
| response: encrypted secret | |
| | |
+-- box.open(response, ephemeralSK) | |
| -> accountSecret (32 bytes) | |
+-- Save { token, secret } | |
| to ~/.happy/agent.key | |
| | |
+-- Derive content keypair: | |
| deriveKey(secret, | |
| 'Happy EnCoder', ['content']) | |
| -> seed -> box keypair | |
| (publicKey for encrypting | |
| per-session keys, | |
| secretKey for decrypting them) | |
v Authenticated | |
```
### Credential File Format (`~/.happy/agent.key`)
```json
{
"token": "jwt-auth-token",
"secret": "base64-encoded-32-byte-account-secret"
}
```
At load time, the content keypair is derived from the secret:
```
secret (32 bytes)
-> deriveKey(secret, 'Happy EnCoder', ['content'])
-> seed (32 bytes)
-> sha512(seed)[0:32] -> boxSecretKey
-> tweetnacl.box.keyPair.fromSecretKey(boxSecretKey)
-> { publicKey (32 bytes), secretKey (32 bytes) }
```
### Key Derivation Tree
```
HMAC-SHA512 based key tree (matches mobile app implementation):
deriveSecretKeyTreeRoot(seed, usage):
I = HMAC-SHA512(key = UTF8(usage + ' Master Seed'), data = seed)
key = I[0:32], chainCode = I[32:64]
deriveSecretKeyTreeChild(chainCode, index):
data = [0x00, ...UTF8(index)]
I = HMAC-SHA512(key = chainCode, data = data)
key = I[0:32], chainCode = I[32:64]
deriveKey(master, usage, path):
state = deriveSecretKeyTreeRoot(master, usage)
for each element in path:
state = deriveSecretKeyTreeChild(state.chainCode, element)
return state.key
Test vectors:
seed = UTF8('test seed'), usage = 'test usage', path = ['child1', 'child2']
Root key: E6E55652456F9FE47D6FF46CA3614E85B499F77E7B340FBBB1553307CEDC1E74
Final key: 1011C097D2105D27362B987A631496BBF68B836124D1D072E9D1613C6028CF75
```
### Encryption
**For new sessions (created by happy-agent):**
1. Generate random 32-byte per-session key
2. Encrypt per-session key with master publicKey via `libsodiumEncryptForPublicKey` → store as `dataEncryptionKey` on server
3. Encrypt/decrypt all session data (metadata, messages, agentState) with AES-256-GCM using the per-session key
**For existing sessions (created by happy-cli or other clients):**
1. If session has `dataEncryptionKey`: strip version byte `[0]`, `decryptBoxBundle(encrypted, contentKeyPair.secretKey)` → per-session AES key, use AES-256-GCM
2. If session has no `dataEncryptionKey`: use `secret` directly as key with legacy TweetNaCl secretbox
**AES-256-GCM bundle format:** `[1-byte version=0][12-byte nonce][ciphertext][16-byte auth tag]`
**Legacy secretbox bundle format:** `[24-byte nonce][ciphertext + MAC]`
**Box encryption bundle format:** `[32-byte ephemeral pubkey][24-byte nonce][ciphertext]`
### Idle Detection Logic
Agent is considered idle when ALL of these are true:
1. `agentState.controlledByUser` is not `true`
2. `agentState.requests` is empty or undefined (no pending tool calls)
3. Session metadata `lifecycleState` is not `'archived'`
### Dependencies (minimal)
- `axios` — HTTP client
- `socket.io-client` — WebSocket communication
- `tweetnacl` — Encryption (box for key exchange, secretbox for legacy, sign for auth challenge)
- `zod` — Runtime validation
- `chalk` — Terminal colors
- `commander` — CLI argument parsing
- `qrcode-terminal` — QR code display for authentication
## Post-Completion
**Manual verification:**
- Test full auth flow: run `happy-agent auth login`, scan QR with Happy app, verify credentials saved
- Test with real server: create session, send message, verify it appears in mobile app
- Test `wait` command with a running agent session
- Test `history` command for sessions created by both `happy-agent` and `happy-cli`
- Test cross-client interop: messages from happy-agent readable by mobile app and vice versa
**Distribution:**
- Package can be published to npm as `happy-agent`
- Alternatively, users install from monorepo via `yarn workspace happy-agent build`
+334
View File
@@ -0,0 +1,334 @@
# `happy server` — bundled self-host mode
## Goal
Ship a zero-network, fully self-hosted Happy in the regular `npm i -g happy` package. One foreground command — `happy server` — runs the sync server + web app on `localhost` and writes the local URL into `~/.happy/settings.json`. Every other Happy process (daemon, `happy claude`, etc.) reads that URL from settings. No analytics, no external endpoints, no extra installs.
## Bootstrap (the v1 user flow)
```bash
# Terminal 1 — start the server (blocks; Ctrl-C to stop)
happy server
# On first start, prints:
# Wrote settings.serverUrl = http://127.0.0.1:3005
# Happy server listening at http://127.0.0.1:3005
# Open http://127.0.0.1:3005 in your browser
# Terminal 2 — daemon and CLI now target localhost (read from settings)
happy daemon start
happy claude
# Web UI at http://127.0.0.1:3005 — pair / start sessions from there
```
That's it. No daemon flags, no `--serve` flag on `daemon start`. Two commands, both already in the user's muscle memory shape.
## Design decisions
### 1. `happy server` runs the server synchronously in the foreground
- No daemonization, no spawn, no background process management.
- Ctrl-C stops it. If it dies, things break — that's the point.
- "Should I daemonize this?" is a v2 question. v1 leans on the user to keep a terminal open (or run it under their own systemd unit / tmux / whatever).
### 2. Single settings field: `settings.serverUrl`
Add to `Settings` in `packages/happy-cli/src/persistence.ts:37`:
```ts
interface Settings {
...existing...
serverUrl?: string; // e.g. 'http://127.0.0.1:3005' — when set, used by all happy processes
}
```
That's the entire persistence surface. No nested `selfHost: { enabled, port, host, dataDir }` object.
- `happy server` writes this on startup.
- `happy server --port 8080 --host 0.0.0.0` writes `http://0.0.0.0:8080` (or printed LAN IP).
- `happy server --reset` clears the field and exits without starting the server.
- Users can also just edit `~/.happy/settings.json` by hand.
### 3. No fallback. Period.
If `settings.serverUrl` is set, everything uses it. If the server is dead, requests fail loudly. We do **not** auto-fall-back to `api.cluster-fluster.com`, do **not** auto-retry against another URL, do **not** silently switch modes.
Rationale: self-host users explicitly opted in. Silent fallback to the public server is the worst possible failure mode — exfiltrates data the user thought was staying local. Fail closed.
### 4. URL precedence (one line of new logic)
`packages/happy-cli/src/configuration.ts:32` becomes:
```ts
this.serverUrl =
process.env.HAPPY_SERVER_URL // env var still wins (debug/override)
|| settings.serverUrl // NEW — from ~/.happy/settings.json
|| 'https://api.cluster-fluster.com'; // default unchanged
```
For existing users: `settings.serverUrl` is `undefined` → falls through to default. **No change.**
### 5. Web app — same-origin trick
Happy-server serves the bundled webapp on its own port. At serve time, the static handler rewrites `index.html` to inject:
```html
<script>window.__HAPPY_CONFIG__ = { serverUrl: location.origin, disableAnalytics: true };</script>
```
And `packages/happy-app/sources/sync/serverConfig.ts:10-14` gets one extra fallback:
```ts
export function getServerUrl(): string {
return serverConfigStorage.getString(SERVER_KEY) ||
(globalThis as any).__HAPPY_CONFIG__?.serverUrl || // NEW
process.env.EXPO_PUBLIC_HAPPY_SERVER_URL ||
DEFAULT_SERVER_URL;
}
```
Production `app.happy.engineering` never has `__HAPPY_CONFIG__` set → no behavior change there. Self-host injects it → web calls its own origin. One bundle, two behaviors.
### 6. Daemon needs no changes
Because the daemon reads `configuration.serverUrl` like every other entry point, and `configuration` now reads from `settings.serverUrl`, the daemon Just Works™ when a user has run `happy server`. Zero code in `daemon/run.ts`. (v2 can add daemon-managed lifecycle if needed.)
## Disabling analytics (defense in depth)
### State of the world today
- **happy-cli, happy-agent, happy-server: no analytics at all** (verified — see Audit reference below).
- **happy-app (web + mobile): PostHog only**, already gated: `tracking = config.postHogKey ? new PostHog(...) : null` (`packages/happy-app/sources/track/tracking.ts:4`). Every call site is `tracking?.capture(...)`, so absence of the key already short-circuits everything.
### Three-layer kill switch
The existing gate is the key. We add two more layers so analytics is impossible in self-host mode regardless of build flags or user mistakes.
**Layer 1 — Existing key gate (already works).** Build the package without `EXPO_PUBLIC_POSTHOG_API_KEY``config.postHogKey` is undefined → `tracking` is `null` → no PostHog instance, no events, no network.
**Layer 2 — Runtime env var kill switch (NEW).** Patch `tracking.ts`:
```ts
import { config } from '@/config';
import PostHog from 'posthog-react-native';
const analyticsDisabled =
process.env.EXPO_PUBLIC_DISABLE_ANALYTICS === '1' ||
process.env.EXPO_PUBLIC_DISABLE_ANALYTICS === 'true' ||
(globalThis as any).__HAPPY_CONFIG__?.disableAnalytics === true;
export const tracking = (analyticsDisabled || !config.postHogKey)
? null
: new PostHog(config.postHogKey, {
host: 'https://us.i.posthog.com',
captureAppLifecycleEvents: true,
});
```
Two ways to flip it:
- `EXPO_PUBLIC_DISABLE_ANALYTICS=1` at build OR runtime (Expo public env vars are inlined into the bundle, but `process.env` lookups remain queryable on web).
- `window.__HAPPY_CONFIG__.disableAnalytics = true` injected by happy-server's static handler.
**Layer 3 — Self-host mode auto-disables.** The HTML rewrite in `happy server` always includes `disableAnalytics: true`:
```ts
injectHtmlConfig: { serverUrl: '/', disableAnalytics: true }
```
So any webapp served by `happy server` has analytics off regardless of what was baked into the bundle at build time. If a user's enterprise rebuilds happy with their own PostHog key (intentionally), they still can't accidentally leak when they `happy server` — the script-tag injection wins.
### What this does NOT do (yet, optional)
- Doesn't remove the `posthog-react-native` dependency from the bundle. Code is still there, just unreachable.
- Truly auditable "no PostHog code at all" would need a build-time stub swap (e.g. Metro resolver alias `tracking.ts``tracking.empty.ts`). Listed as a v2 idea.
### CLI / server / agent — nothing to add
No analytics code exists. Nothing to disable. (If we ever add CLI telemetry in the future, the same env var name `HAPPY_DISABLE_ANALYTICS=1` should be the kill switch — drop `EXPO_PUBLIC_` prefix.)
## How current users are unaffected
Three guarantees, all hinging on `settings.serverUrl === undefined` for existing users:
1. **Settings default**: optional field, missing in existing `settings.json`. `readSettings()` returns `undefined`.
2. **Configuration URL**: middle clause in precedence chain is `undefined` → falls through to current default. Byte-identical behavior.
3. **Web app**: `window.__HAPPY_CONFIG__` only ever set by the static handler in self-host mode. Production builds at `app.happy.engineering` never see it.
The only real cost: **~30 MB extra in the npm tarball** for the bundled webapp + pglite wasm + migrations. Paid by all users, even non-self-hosters. Acceptable on a 121 MB package; mitigated by the orthogonal `difft` cleanup (could free ~70 MB).
## Tarball layout
```
happy@1.x.x/
├── dist/ existing — bundled JS (now imports @slopus/happy-server)
├── bin/ existing
├── tools/ existing — ripgrep + difft binaries
├── server-assets/ NEW (~30 MB total)
│ ├── webapp/ pre-built happy-app web export (~28 MB)
│ │ ├── index.html
│ │ ├── _expo/
│ │ └── assets/
│ ├── pglite/
│ │ ├── pglite.wasm
│ │ └── pglite.data
│ └── migrations/ Prisma SQL migration files
├── scripts/ existing
└── package.json + "server-assets" in files array
```
Runtime path resolution from bundled `dist/index.{cjs,mjs}`:
```ts
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
const WEBAPP_DIR = join(ROOT, 'server-assets', 'webapp')
const MIGRATIONS = join(ROOT, 'server-assets', 'migrations')
const PGLITE_WASM = join(ROOT, 'server-assets', 'pglite', 'pglite.wasm')
```
PGlite gets the wasm path passed explicitly so bundling doesn't break the lookup.
## Implementation sketch
### New files
**`packages/happy-cli/src/commands/server.ts`** (~80 LOC)
```
happy server start server, write URL, block
happy server --port N custom port
happy server --host 0.0.0.0 bind for LAN/mobile
happy server --reset clear settings.serverUrl, exit
happy server --no-persist start server but don't touch settings (test mode)
```
Flow: parse args → `updateSettings({ serverUrl })` (unless `--no-persist`) → `migrate(...)``startServer(...)` → log URL → await server close on SIGINT/SIGTERM.
**`packages/happy-cli/scripts/build-server-assets.mjs`** (~50 LOC, build-time)
- `pnpm --filter @slopus/happy-app build:web`
- Copy webapp dist, pglite wasm, server migrations → `packages/happy-cli/server-assets/`
**`packages/happy-server/sources/index.ts`** (NEW, ~40 LOC)
Export from refactored `standalone.ts`:
```ts
export async function migrate(opts: { pgliteDir: string; migrationsDir: string }): Promise<void>
export async function startServer(opts: {
port: number; host: string; pgliteDir: string; masterSecret: string;
staticDir?: string; injectHtmlConfig?: Record<string, unknown>;
}): Promise<{ close(): Promise<void>; url: string }>
```
When `staticDir` set, register `@fastify/static` on `/*` with an `onSend` hook that prepends the `__HAPPY_CONFIG__` script tag into `index.html`.
### Modified files
**`packages/happy-cli/package.json`**
- Dep: `"@slopus/happy-server": "workspace:*"` (only — fastify-static and pglite come transitively)
- `files`: add `"server-assets"`
- `build`: run `build-server-assets.mjs` before `pkgroll`
**`packages/happy-cli/src/index.ts`**
- One new subcommand branch for `server` (~10 LOC)
**`packages/happy-cli/src/persistence.ts`**
- Add `serverUrl?: string` to `Settings` (~2 LOC)
**`packages/happy-cli/src/configuration.ts`**
- Add `settings.serverUrl` to URL precedence (~3 LOC, but needs sync settings read at bootstrap)
**`packages/happy-server/package.json`**
- Flip `"private": false`
- Rename to `"@slopus/happy-server"`
- Add `"main": "./sources/index.ts"` + `exports` map
- Add `@fastify/static` dep
**`packages/happy-app/sources/sync/serverConfig.ts`**
- 3-line `__HAPPY_CONFIG__` fallback
**`packages/happy-app/sources/track/tracking.ts`**
- 3-layer kill switch (env var + `__HAPPY_CONFIG__.disableAnalytics`) (~8 LOC)
## Net LOC
| File | LOC |
|---|---|
| `commands/server.ts` | ~80 |
| `persistence.ts` (settings field) | ~2 |
| `configuration.ts` (URL precedence) | ~3 |
| `@slopus/happy-server/index.ts` exports | ~40 |
| Build script | ~50 |
| `serverConfig.ts` webapp fallback | 3 |
| `tracking.ts` analytics kill switch | ~8 |
| `index.ts` router | ~10 |
| **Total new code** | **~196** |
No daemon changes. Bundle adds ~30 MB to the npm tarball.
## Master secret
- Generated on first `happy server` start: 32 random bytes → `~/.happy/server/master-secret` (0600 perms).
- Reused on subsequent starts.
- If deleted, all self-host data becomes unreadable. Print loud warning in `happy server --reset` and in startup logs.
- No derivation from existing happy credentials — keep self-host data independent.
## Data directory layout (per-user)
```
~/.happy/
├── settings.json existing — now also holds serverUrl?
├── access.key existing
├── daemon.state.json existing
├── logs/ existing
└── server/ NEW — only created by `happy server`
├── master-secret 32 random bytes, 0600
└── db/ PGlite data directory
└── ...
```
## Open questions
1. **Bind host default**`127.0.0.1` (single-user desktop, mobile can't connect) or `0.0.0.0` (mobile works on LAN, exposes port). Default `127.0.0.1`; flag `--host 0.0.0.0` documented; print warning on non-loopback binding.
2. **Prisma engines** — verify `pglite-prisma-adapter` actually sidesteps native query engines after pkgroll bundling. If not, per-platform optional deps.
3. **Sharp dep in happy-server** — ~20 MB native binary per platform. Either npm optional-deps (sharp does this itself) or stub out image processing for the self-host minimum.
4. **OAuth redirects in `connectRoutes.ts`** — hardcoded `https://app.happy.engineering`. For self-host either env-var (`PUBLIC_WEBAPP_URL`) or disable the GitHub-connect route entirely.
5. **Multi-user vs single-user** — server is multi-tenant. Self-host probably wants "first client auto-pairs, rest rejected". Out of v1 scope.
## Suggested implementation order
1. **Analytics kill switch first** — patch `tracking.ts` with the 3-layer guard. Tiny PR, lands immediately, useful even without self-host.
2. Export `migrate` / `startServer` from `@slopus/happy-server`, flip `private: false`, add `@fastify/static`. No functional change anywhere else. Verify package publishes cleanly.
3. Add `window.__HAPPY_CONFIG__` fallback to `serverConfig.ts`. No behavior change in production.
4. Build-assets script + `server-assets/` directory. Verify `pkgroll` includes it.
5. `settings.serverUrl` field + `configuration.ts` precedence change. Test that existing users see no change (undefined → fallthrough).
6. `happy server` command end-to-end. Smoke test: `happy server` in one terminal, `happy claude` in another, verify CLI targets localhost.
7. Docs / README.
8. Release.
## v2 ideas (not now)
- Daemon-managed server lifecycle (`happy daemon start --server`, auto-restart, launchd integration).
- Process isolation so a server crash doesn't take the daemon down.
- Admin-token / auto-pair-first-client for single-user enforcement.
- `happy server status` / `happy server stop` as ergonomics on top of the foreground command.
- Build-time stub swap to fully remove `posthog-react-native` from the bundle (Metro resolver alias `tracking.ts` → empty module).
- Parameterize the 5 cosmetic `happy.engineering` URLs.
- `PUBLIC_WEBAPP_URL` for happy-server OAuth redirects.
## Orthogonal cleanups worth doing
- **113 MB `difft` binary** in the published tarball — almost certainly a multi-platform blob. Split into per-platform optional deps → ~70 MB saved per install, more than offsets the new self-host assets.
- The 5 hardcoded `happy.engineering` URLs in CLI / settings / system prompt.
## Audit reference (state of the repo when this plan was written)
### Analytics
Only `happy-app` has PostHog (`packages/happy-app/sources/track/tracking.ts:4`), already gated by `config.postHogKey`. CLI / agent / server have zero analytics (grepped).
### Server URL configurability (already exists)
- CLI / agent: `HAPPY_SERVER_URL` env var (default `https://api.cluster-fluster.com`).
- Web app: in-app Settings → Server screen + `EXPO_PUBLIC_HAPPY_SERVER_URL`.
- Server: `pnpm standalone:dev` runs with PGlite — no Docker, no Redis. Entry: `packages/happy-server/sources/standalone.ts`.
### Hardcoded `happy.engineering` URLs (cosmetic)
- `happy-app/sources/components/SettingsView.tsx:396` — privacy link
- `happy-cli/src/ui/doctor.ts:225`, `src/claude/utils/systemPrompt.ts:20-23`, `src/commands/connect.ts:74` — docs / attribution
- `happy-server/sources/app/api/routes/connectRoutes.ts` — GitHub OAuth redirect
- `happy-app/app.config.js:46,77` + iOS/Android manifests — universal link domain
### Published package size
`happy@1.1.7` on npm: **121 MB packed / 238 MB unpacked**. Dominated by `tools/unpacked/difft` (113 MB) + ripgrep platform archives (~27 MB).
@@ -0,0 +1,238 @@
# Metadata-Driven Model and Mode Selection on Client
## Overview
- Make the client use backend-provided metadata fields (`metadata.models[]`, `metadata.operatingModes[]`, `metadata.currentModelCode`, `metadata.currentOperatingModeCode`) for model and mode selection in active sessions, instead of hardcoding options per agent type
- Principle: if metadata provides options, use them; otherwise fall back to hardcoded defaults
- Models: always prefer metadata when available (all agent types)
- Modes: hardcoded for claude/codex, metadata-driven for others when available
- Send model selection via message meta for all agent types (not just Gemini)
- Both `ModelMode` and `PermissionMode` become structured types `{ key: string; name: string; description?: string | null }` — UI shows `name` everywhere, `key` is the value sent to backend/stored
## Context (from discovery)
- Backend already emits `config_options_update`, `modes_update`, `models_update` events and populates `metadata.models[]`, `metadata.operatingModes[]`, `metadata.currentModelCode`, `metadata.currentOperatingModeCode`
- Metadata shape: `{ code: string; value: string; description?: string | null }` — maps as `{ key: code, name: value, description }`
- Frontend currently hardcodes: Claude (sonnet/opus), Codex (gpt-5-*), Gemini (gemini-2.5-*)
- `ModelMode` type is currently a flat string union
- `PermissionMode` type is currently a flat string union (`'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'`)
- `Session.modelMode` is restricted to `'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite'`
- `Session.permissionMode` is restricted to the `PermissionMode` string union
- `updateSessionModelMode()` and `updateSessionPermissionMode()` in storage only accept those specific strings
- `sendMessage()` only sends model in meta for Gemini sessions
- Both mode and model selectors are hardcoded per agent type in AgentInput.tsx
## Files involved
| File | Change |
|------|--------|
| `packages/happy-app/sources/components/PermissionModeSelector.tsx` | Change both `ModelMode` and `PermissionMode` to `{ key, name, description }` struct |
| `packages/happy-app/sources/sync/storageTypes.ts` | Change `Session.modelMode` and `Session.permissionMode` to `string | null` (store key only) |
| `packages/happy-app/sources/sync/storage.ts` | Widen both `updateSessionModelMode()` and `updateSessionPermissionMode()` to accept `string` |
| `packages/happy-app/sources/components/AgentInput.tsx` | Accept structs for both, render from metadata or hardcoded, show `name` in UI |
| `packages/happy-app/sources/-session/SessionView.tsx` | Build structs from metadata for both model and mode, pass to AgentInput |
| `packages/happy-app/sources/sync/sync.ts` | Send model key in meta for all agent types, send permission mode key |
| `packages/happy-app/sources/sync/typesMessageMeta.ts` | Ensure meta schema accepts any string for permissionMode |
| `packages/happy-app/sources/app/(app)/new/index.tsx` | Adapt to structs for both model and mode |
## Development Approach
- **Testing approach**: Regular (code first, then tests)
- Complete each task fully before moving to the next
- Make small, focused changes
- **CRITICAL: every task MUST include new/updated tests** for code changes in that task
- **CRITICAL: all tests must pass before starting next task**
- **CRITICAL: update this plan file when scope changes during implementation**
- Run tests after each change
- Maintain backward compatibility
## Testing Strategy
- **Unit tests**: required for every task (see Development Approach above)
## Progress Tracking
- Mark completed items with `[x]` immediately when done
- Add newly discovered tasks with prefix
- Document issues/blockers with ⚠️ prefix
- Update plan if implementation deviates from original scope
- Keep plan in sync with actual work done
- ⚠️ `yarn workspace happy-app lint` is unavailable because the package has no `lint` script
## Implementation Steps
### Task 1: Change ModelMode and PermissionMode types to structs
- [x] Change `ModelMode` type in `PermissionModeSelector.tsx` from flat string union to `{ key: string; name: string; description?: string | null }`
- [x] Change `PermissionMode` type in `PermissionModeSelector.tsx` from flat string union to `{ key: string; name: string; description?: string | null }`
- [x] Change `Session.modelMode` in `storageTypes.ts` from hardcoded union to `string | null` (stores key only)
- [x] Change `Session.permissionMode` in `storageTypes.ts` from hardcoded union to `string | null` (stores key only)
- [x] Change `updateSessionModelMode()` in `storage.ts` to accept `string`
- [x] Change `updateSessionPermissionMode()` in `storage.ts` to accept `string`
- [x] Update `permissionMode` in `MessageMetaSchema` in `typesMessageMeta.ts` from enum to `z.string()` (keys are now arbitrary strings)
- [x] Fix TypeScript compilation errors from type changes across the codebase
- [x] Run tests - must pass before next task
### Task 2: Build hardcoded PermissionMode and ModelMode struct lists
- [x] Create helper constants for hardcoded Claude permission modes as struct arrays: `[{ key: 'default', name: 'Default' }, { key: 'acceptEdits', name: 'Accept Edits' }, ...]`
- [x] Create helper constants for hardcoded Codex/Gemini permission modes as struct arrays: `[{ key: 'default', name: 'Default' }, { key: 'read-only', name: 'Read Only' }, ...]`
- [x] Create helper constants for hardcoded model modes per agent type (Claude: sonnet/opus, Codex: gpt-5-*, Gemini: gemini-2.5-*)
- [x] Mode `name` is simply the `key` capitalized (e.g. `"plan"``"Plan"`, `"build"``"Build"`) — no translation keys needed for mode names
- [x] Write tests for struct constants having correct keys and names
- [x] Run tests - must pass before next task
### Task 3: Update SessionView to build structs from metadata
- [x] Build `ModelMode` struct for current model: look up `session.modelMode` key in `metadata.models[]`, fall back to `metadata.currentModelCode`, or null
- [x] Build `PermissionMode` struct for current mode: look up `session.permissionMode` key in `metadata.operatingModes[]` (for non-claude/codex) or hardcoded list (for claude/codex)
- [x] Build `availableModels` list: use `metadata.models[]` if non-empty, else hardcoded fallback for known agents
- [x] Build `availableModes` list: use hardcoded for claude/codex, use `metadata.operatingModes[]` for others if non-empty
- [x] Update `updateModelMode` callback: extract `key` from struct, call `updateSessionModelMode(key)`
- [x] Update `updatePermissionMode` callback: extract `key` from struct, call `updateSessionPermissionMode(key)`
- [x] Pass structs and available lists to `AgentInput`
- [x] Write tests for struct construction from metadata
- [x] Write tests for fallback when metadata is empty
- [x] Run tests - must pass before next task
### Task 4: Update AgentInput to render from structs
- [x] Update props: `modelMode``ModelMode | null`, `permissionMode``PermissionMode | null`
- [x] Add `availableModels` and `availableModes` props (arrays of structs)
- [x] In model section: render from `availableModels`, show `name` as label, `description` as subtitle, compare by `key`
- [x] In mode section: render from `availableModes`, show `name` as label, compare by `key`
- [x] On model selection: call `onModelModeChange(struct)` with full struct
- [x] On mode selection: call `onPermissionModeChange(struct)` with full struct
- [x] Update status bar: show `modelMode.name` and `permissionMode.name` instead of hardcoded label lookups
- [x] Update keyboard shortcut (Shift+Tab) to cycle through `availableModes` structs
- [x] Write tests for rendering from struct arrays
- [x] Write tests for selection callbacks passing full structs
- [x] Run tests - must pass before next task
### Task 5: Send model and mode keys in message meta for all agent types
- [x] In `sync.ts` `sendMessage()`, read `session.modelMode` (key string) and send in `meta.model` when set and not `'default'` — for ALL agent types, not just Gemini
- [x] Read `session.permissionMode` (key string) and send in `meta.permissionMode`
- [x] Remove Gemini-specific model logic and hardcoded default fallbacks
- [x] Write tests for sendMessage including model key for non-Gemini agents
- [x] Write tests for sendMessage sending permission mode key
- [x] Run tests - must pass before next task
### Task 6: Update new session wizard
- [x] Update `modelMode` state to `ModelMode | null` struct
- [x] Update `permissionMode` state to `PermissionMode` struct
- [x] Build structs from hardcoded defaults per agent type (metadata not available at creation time)
- [x] On session creation, call `updateSessionModelMode(modelMode.key)` and `updateSessionPermissionMode(permissionMode.key)`
- [x] Update `lastUsedModelMode` / `lastUsedPermissionMode` settings to store/restore keys
- [x] Write tests for struct construction in wizard
- [x] Run tests - must pass before next task
### Task 7: Verify acceptance criteria
- [x] Verify model selector shows `name` from metadata when metadata.models is populated
- [x] Verify model selector falls back to hardcoded names when metadata is empty
- [x] Verify mode selector shows `name` from metadata for non-claude/codex agents
- [x] Verify mode selector shows hardcoded names for claude/codex
- [x] Verify model `key` (not name) is sent in meta.model for all agent types
- [x] Verify permission mode `key` (not name) is sent in meta.permissionMode
- [x] Verify `name` is shown in status bar, selectors, and badges — never raw `key`
- [x] Run full test suite (unit tests)
- [ ] Run linter - all issues must be fixed
### Task 8: [Final] Update documentation
- [x] Update README.md if needed
- [x] Update project knowledge docs if new patterns discovered
## Technical Details
### Shared struct type for both Model and Mode
```typescript
// Both ModelMode and PermissionMode use the same shape
type ModelMode = {
key: string; // Technical ID sent to backend (e.g. "gemini-2.5-pro")
name: string; // Display name shown in UI (e.g. "Gemini 2.5 Pro")
description?: string | null; // Optional subtitle (e.g. "Most capable")
};
type PermissionMode = {
key: string; // Technical ID sent to backend (e.g. "plan", "build")
name: string; // Display name = key capitalized (e.g. "Plan", "Build")
description?: string | null; // Optional subtitle
};
```
### Mapping from metadata
```typescript
// metadata.models[] → ModelMode[]
metadata.models.map(m => ({
key: m.code,
name: m.value,
description: m.description
}))
// metadata.operatingModes[] → PermissionMode[]
metadata.operatingModes.map(m => ({
key: m.code,
name: m.value,
description: m.description
}))
```
### Hardcoded fallbacks (examples)
```typescript
// Claude permission modes — name is just key capitalized
const CLAUDE_PERMISSION_MODES: PermissionMode[] = [
{ key: 'default', name: 'Default' },
{ key: 'acceptEdits', name: 'Accept Edits' },
{ key: 'plan', name: 'Plan' },
{ key: 'bypassPermissions', name: 'Bypass Permissions' },
];
// Gemini models (fallback when metadata not available)
const GEMINI_MODELS: ModelMode[] = [
{ key: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', description: 'Most capable' },
{ key: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', description: 'Fast & efficient' },
{ key: 'gemini-2.5-flash-lite', name: 'Gemini 2.5 Flash Lite', description: 'Fastest' },
];
```
### Storage: stores key only
```typescript
// storageTypes.ts
Session.modelMode?: string | null; // Key only (e.g. "gemini-2.5-pro")
Session.permissionMode?: string | null; // Key only (e.g. "acceptEdits")
// storage.ts
updateSessionModelMode(sessionId: string, key: string)
updateSessionPermissionMode(sessionId: string, key: string)
```
### Data flow (after changes)
```
Backend emits metadata:
metadata.models = [{ code: "gemini-2.5-pro", value: "Gemini 2.5 Pro", description: "Most capable" }, ...]
metadata.currentModelCode = "gemini-2.5-pro"
metadata.operatingModes = [{ code: "default", value: "Default" }, ...]
metadata.currentOperatingModeCode = "default"
SessionView builds structs:
modelMode = { key: "gemini-2.5-pro", name: "Gemini 2.5 Pro", description: "Most capable" }
permissionMode = { key: "default", name: "Default", description: null }
availableModels = metadata.models → ModelMode[]
availableModes = hardcoded (claude/codex) OR metadata.operatingModes → PermissionMode[]
AgentInput renders:
Shows "Gemini 2.5 Pro" (name) in model selector and status bar
Shows "Default" (name) in mode selector and status bar
On selection → calls onChange with full { key, name, description } struct
SessionView handles change:
Extracts key → calls updateSessionModelMode("gemini-2.5-pro")
Extracts key → calls updateSessionPermissionMode("acceptEdits")
sendMessage():
Reads session.modelMode ("gemini-2.5-pro") → sends as meta.model (ALL agents)
Reads session.permissionMode ("acceptEdits") → sends as meta.permissionMode
```
## Post-Completion
**Manual verification:**
- Test with a Gemini ACP session to verify metadata-driven model and mode selectors show names
- Test with a Claude session to verify hardcoded mode names are preserved
- Test with a custom ACP agent that provides metadata to verify dynamic rendering
- Verify keys (not names) are sent in message meta and received by backend
- Verify names (not keys) are shown in all UI locations (selectors, status bar, badges)
+32
View File
@@ -0,0 +1,32 @@
# pnpm Migration [Draft]
## Status: No-op for now
The `expo doctor` duplicate dependency warnings are a Yarn 1 monorepo hoisting issue, not a correctness bug. Quick fixes (expanding `nohoist`, adding `resolutions`) can silence the dangerous duplicates without a package manager migration.
## Why pnpm eventually
- Expo team is leaning pnpm (byCedric's official expo-monorepo-example uses it)
- Faster installs, especially in worktree flows (no re-downloading, content-addressable store)
- Dominant choice for new JS/TS monorepos (~20% market share, fastest growing)
- `pnpm.overrides` cleaner than Yarn `resolutions` for pinning singletons
## Why not now
- React Native requires `node-linker=hoisted`, which negates pnpm's strict isolation — you end up with a similar flat layout anyway
- pnpm symlinks + git worktrees (`.dev/worktree/`) need testing — symlinks may resolve to wrong store location
- pnpm v11 beta just dropped (March 2026) with breaking config changes — migrating now means potentially migrating again
- Half-day of work + risk for moderate gain
## When to reconsider
- If quick Yarn 1 fixes don't stick
- When pnpm v11 stabilizes
- When Expo SDK improves isolated dependency support enough to drop `node-linker=hoisted`
## Quick fixes (current plan)
1. Remove `@expo/config-plugins` from happy-app/package.json
2. Run `npx expo install --check` for version mismatches
3. Expand `nohoist` in root package.json for duplicating expo packages
4. Add `resolutions` to pin `react` and `react-native` to single versions
+102
View File
@@ -0,0 +1,102 @@
# Portable Single-Binary Distribution
## Overview
Create a portable, self-contained distribution of happy-server as a single Bun-compiled binary. It runs without Redis (already has in-memory event bus), uses PGlite for embedded PostgreSQL, and local filesystem for file storage. CLI provides `happy-server migrate` and `happy-server serve` commands.
## Context
- **Event bus**: Already 100% in-memory (`eventRouter.ts`). Redis is only used for `redis.ping()` health check — zero actual pub/sub usage. `@socket.io/redis-streams-adapter` is a dependency but never imported in source code.
- **Database**: Prisma with PostgreSQL. `pglite-prisma-adapter` provides a Prisma driver adapter for PGlite. Requires `driverAdapters` preview feature in schema.
- **File storage**: S3/Minio used for image uploads (avatar uploads via GitHub connect). Used in `uploadImage.ts`, `files.ts`, referenced in `eventRouter.ts`, `accountRoutes.ts`, `type.ts`.
- **Migrations**: 36 SQL migration files in `prisma/migrations/`. PGlite adapter doesn't support `prisma migrate` CLI, so we apply SQL files directly via PGlite.
- **Bun+PGlite compile limitation**: There's a [known Bun issue](https://github.com/oven-sh/bun/issues/15032) where PGlite WASM files can't be embedded in `--compile` output. Workaround: copy `postgres.data`/`postgres.wasm` files next to binary.
## Development Approach
- Complete each task fully before moving to the next
- Make small, focused changes
- Minimal changes to existing code — prefer conditional paths over rewrites
- Test by running the standalone binary after build
## Implementation Steps
### Task 1: Add PGlite + adapter dependencies
- [ ] Add `@electric-sql/pglite`, `pglite-prisma-adapter` to happy-server dependencies
- [ ] Add `driverAdapters` to `previewFeatures` in `prisma/schema.prisma` generator block
- [ ] Run `prisma generate` to regenerate client with adapter support
- [ ] Verify existing server still works (no breaking changes from preview feature)
### Task 2: Make database layer PGlite-aware
- [ ] Modify `sources/storage/db.ts` to conditionally use PGlite when `PGLITE_DIR` env var is set
- If `PGLITE_DIR` is set: create PGlite instance with that dir, wrap in `PrismaPGlite` adapter, pass to `new PrismaClient({ adapter })`
- If not set: use existing `new PrismaClient()` (connects via `DATABASE_URL` as before)
- [ ] Export a `getPGlite()` function for direct SQL access (needed by migration command)
### Task 3: Make Redis optional
- [ ] In `main.ts`, make `redis.ping()` conditional — only if `REDIS_URL` env var is set
- [ ] Skip redis import when not needed (dynamic import or guard)
### Task 4: Replace S3 with local filesystem storage
- [ ] Modify `sources/storage/files.ts`:
- If S3 env vars are set: use existing Minio client (no change)
- If not: use local filesystem under `DATA_DIR/files/` directory
- [ ] Modify `sources/storage/uploadImage.ts`:
- Replace `s3client.putObject` with conditional: S3 or `fs.writeFile` to local path
- Replace `resolveImageUrl` to return local file-serving URL when in local mode
- [ ] Add a static file serving route in API for local files (e.g., `/files/*`)
### Task 5: Create CLI entry point with migrate command
- [ ] Create `sources/standalone.ts` as the portable entry point:
- Parse `process.argv` for subcommands: `migrate`, `serve`
- `migrate`: initialize PGlite directly, read all `prisma/migrations/*/migration.sql` files in order, execute them via PGlite SQL, track applied migrations
- `serve`: call existing `main()` logic
- No args or `--help`: print usage
- [ ] Embed migration SQL files at build time (Bun can import text files)
### Task 6: Add Bun build configuration
- [ ] Add `build:standalone` script to `package.json`: `bun build ./sources/standalone.ts --compile --outfile happy-server`
- [ ] Handle PGlite WASM files: add a post-build step to copy postgres data files next to the binary
- [ ] Test the build: `bun run build:standalone`
- [ ] Test: `./happy-server migrate` creates and migrates a PGlite database
- [ ] Test: `./happy-server serve` starts the server with PGlite
### Task 7: Verify end-to-end
- [ ] Build the binary
- [ ] Run `./happy-server migrate` — verify database is created in `./data/`
- [ ] Run `./happy-server serve` — verify server starts, health endpoint responds
- [ ] Verify no Redis connection attempted
- [ ] Verify files can be stored/served locally
## Technical Details
**PGlite initialization:**
```typescript
import { PGlite } from '@electric-sql/pglite';
import { PrismaPGlite } from 'pglite-prisma-adapter';
const pglite = new PGlite(process.env.PGLITE_DIR || './data/pglite');
const adapter = new PrismaPGlite(pglite);
const db = new PrismaClient({ adapter });
```
**Migration approach:**
- Read SQL files from `prisma/migrations/` sorted by directory name (timestamp order)
- Create a `_prisma_migrations` table to track applied migrations
- For each unapplied migration: execute SQL, record in tracking table
- This mirrors what `prisma migrate deploy` does
**Data directory structure:**
```
./data/ (or $DATA_DIR)
pglite/ # PGlite database files
files/ # Uploaded files (replaces S3)
public/users/... # Same path structure as S3
```
**CLI usage:**
```
happy-server migrate # Apply database migrations
happy-server serve # Start the server
```
## Post-Completion
- Document env vars for portable mode (`PGLITE_DIR`, `DATA_DIR`, `HANDY_MASTER_SECRET`)
- Test on Linux for cross-platform binary (Bun cross-compile)
- Consider adding `happy-server init` command for first-time setup
+867
View File
@@ -0,0 +1,867 @@
# Provider Envelope Redesign
Status: **DRAFT — v2 (OpenCode-derived)**
Previous version of this doc proposed `user-message` + `agent-event` as two flat
payload kinds. This revision replaces that with OpenCode's message+parts model,
adapted for Happy's encrypted storage and with permissions/questions unified into
tool state instead of side-channel events.
## Why this exists
The current inner message layer has become too hard to reason about.
Today the app must normalize multiple plaintext payload families after decryption:
- legacy user messages
- legacy agent `output` messages
- legacy `codex` messages
- legacy `acp` messages
- legacy `event` messages
- modern `role: "session"` envelopes
That fan-in is visible in
[`packages/happy-app/sources/sync/typesRaw.ts`](../../packages/happy-app/sources/sync/typesRaw.ts),
and it is the real source of the complexity.
The problem is not transport. Transport is fine (encrypted blobs, ordered
per-session messages, v3 HTTP read/write, Socket.IO invalidation). The messy
part is the **provider envelope shape inside the encrypted message body**.
## Research base
This design is grounded in cross-vendor protocol research:
- `docs/competition/opencode/runtime-tracing.md` — real traced exchanges
- `docs/competition/opencode/message-protocol.md` — protocol analysis
- `docs/competition/codex/message-protocol.md` — approval model reference
- `docs/competition/claude/message-protocol.md` — agent teams reference
- `docs/competition/comparison-matrix.md` — cross-vendor summary
OpenCode source at commit `2e0d5d230893dbddcefb35a02f53ff2e7a58e5d0`:
- `packages/opencode/src/session/message-v2.ts` — message + part schemas
- `packages/opencode/src/session/processor.ts` — tool state machine
- `packages/opencode/src/permission/index.ts` — permission ask/reply flow
- `packages/opencode/src/question/index.ts` — question ask/reply flow
- `packages/opencode/src/tool/todo.ts` — todo tool
- `packages/opencode/src/tool/question.ts` — question tool
## Key decisions up front
### 1. Adopt OpenCode's message+parts shape
Two record types: **Message** (the envelope) and **Part** (ordered content).
Messages are discriminated on `role` (user / assistant). Parts are discriminated
on `type` (text, tool, reasoning, etc).
We are copying this almost verbatim. It is proven in production, we have full
runtime traces, and there is no reason to invent a different shape.
### 2. Permissions and questions live on the tool part — not side-channel
OpenCode puts permissions on a separate SSE event (`permission.asked` /
`permission.replied`). The tool part stays `"running"` while blocked, and the
permission decision is not durably recorded on the tool.
We think this is wrong. In OpenCode:
- If you reload after a session completes, you cannot tell which tool calls
required approval vs which auto-approved
- The app must listen to two separate event types and merge them with tool state
- Permission history is ephemeral
Instead, we add an explicit `"blocked"` status to the tool state machine. The
tool part itself carries the permission/question request and decision. No
separate event types needed — the tool part updates, which emits the same
`message.part.updated` that every other tool state transition uses.
### 3. No special SSE event types for permissions, questions, or todos
A tool state change is a tool state change. When a tool becomes blocked, the
SSE stream emits `message.part.updated` with the tool part showing `status:
"blocked"`. When the user responds, another `message.part.updated` fires with
the tool part showing `status: "running"` or `status: "error"`.
Push notifications (mobile, desktop) are a separate concern — triggered by
whatever state change is relevant, not coupled to protocol event taxonomy.
### 4. Subagents are child sessions
The `task` tool creates a real child session with its own `sessionID`,
`parentID`, and constrained permissions. The parent transcript records the
delegation as a tool part. The child session has its own full transcript.
Resumable by session ID.
### 5. Todos are a tool + side store
`todowrite` is a normal tool — creates a tool part. Separately, it writes to a
todo store (separate table or state). The todo store exists for quick reads
("what are current todos?") without walking the transcript. This is the one
case where a side store earns its keep.
### 6. Patchable canonical messages, not delta replay
Messages are patched in place as they evolve (tool pending → blocked →
running → completed). Sync sends the full updated message. Refetch returns
latest state. We do not adopt OpenCode's raw `message.part.delta` replay as
the durable sync model.
## The model
### Message Info (envelope)
Discriminated on `role`:
```ts
// ── User Message ──────────────────────────────────────────
type UserMessage = {
id: string // "msg_..." ascending
sessionID: string // "ses_..."
role: "user"
time: { created: number }
agent: string // "build" | "explore" | "plan" | ...
model: {
providerID: string // "anthropic" | "openai" | ...
modelID: string // "claude-sonnet-4-6" | ...
}
format?: OutputFormat
system?: string // system prompt snapshot (debugging only)
tools?: Record<string, boolean>
variant?: string
summary?: {
title?: string
body?: string
diffs: FileDiff[]
}
}
// ── Assistant Message ─────────────────────────────────────
type AssistantMessage = {
id: string
sessionID: string
role: "assistant"
time: {
created: number
completed?: number
}
parentID: string // FK → UserMessage.id that triggered this
modelID: string
providerID: string
agent: string
path: {
cwd: string
root: string
}
cost: number
tokens: {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
finish?: string // "stop" | "tool-calls" | "length"
error?: MessageError
summary?: boolean // true if compaction summary
variant?: string
}
type Message = UserMessage | AssistantMessage
```
All usage stats, cost, model info, token counts live here. The app always has
this data without walking parts.
### Parts (ordered content within a message)
Every part shares a base:
```ts
type PartBase = {
id: string // "prt_..." ascending
sessionID: string
messageID: string // FK → Message.id
}
```
Discriminated union on `type`:
```ts
type Part =
| TextPart
| ReasoningPart
| ToolPart
| FilePart
| StepStartPart
| StepFinishPart
| SubtaskPart
| AgentPart
| SnapshotPart
| PatchPart
| CompactionPart
| RetryPart
```
#### TextPart
```ts
type TextPart = PartBase & {
type: "text"
text: string
synthetic?: boolean // injected by system, not typed by user
ignored?: boolean // present but excluded from model context
time?: { start: number; end?: number }
metadata?: Record<string, unknown>
}
```
#### ReasoningPart
```ts
type ReasoningPart = PartBase & {
type: "reasoning"
text: string
time: { start: number; end?: number }
metadata?: Record<string, unknown>
}
```
#### ToolPart — the big one
```ts
type ToolPart = PartBase & {
type: "tool"
callID: string
tool: string // "bash" | "writeFile" | "task" | "question" | ...
state: ToolState
metadata?: Record<string, unknown>
}
```
#### Tool state machine
```
pending ──→ running ──→ completed
├──→ blocked ──→ running ──→ completed
│ │
│ └──→ error (rejected)
└──→ error
```
```ts
type ToolState =
| ToolStatePending
| ToolStateRunning
| ToolStateBlocked
| ToolStateCompleted
| ToolStateError
type ToolStatePending = {
status: "pending"
input: Record<string, unknown>
raw: string // raw input string as it streams in
}
type ToolStateRunning = {
status: "running"
input: Record<string, unknown>
title?: string
metadata?: Record<string, unknown>
time: { start: number }
}
type ToolStateBlocked = {
status: "blocked"
input: Record<string, unknown>
title?: string
metadata?: Record<string, unknown>
time: { start: number }
block: PermissionBlock | QuestionBlock
}
type ToolStateCompleted = {
status: "completed"
input: Record<string, unknown>
output: string
title: string
metadata: Record<string, unknown>
time: { start: number; end: number; compacted?: number }
attachments?: FilePart[]
block?: ResolvedBlock // preserved if tool was blocked before completing
}
type ToolStateError = {
status: "error"
input: Record<string, unknown>
error: string
metadata?: Record<string, unknown>
time: { start: number; end: number }
block?: ResolvedBlock // preserved if tool was blocked before erroring
}
```
#### Block types
```ts
type PermissionBlock = {
type: "permission"
id: string // stable request ID
permission: string // "edit" | "bash" | "task" | ...
patterns: string[] // ["src/api/server.ts"] — what's being accessed
always: string[] // patterns to auto-approve if user says "always"
metadata: Record<string, unknown> // diff payload, filepath, etc
}
type QuestionBlock = {
type: "question"
id: string
questions: QuestionInfo[]
}
type QuestionInfo = {
question: string
header: string // short label (max 30 chars)
options: { label: string; description: string }[]
multiple?: boolean
custom?: boolean // allow free-text answer (default true)
}
// After the user responds, the block becomes resolved and is preserved
// on the completed/error state for audit trail
type ResolvedBlock =
| ResolvedPermissionBlock
| ResolvedQuestionBlock
type ResolvedPermissionBlock = {
type: "permission"
id: string
permission: string
patterns: string[]
metadata: Record<string, unknown>
decision: "once" | "always" | "reject"
decidedAt: number
}
type ResolvedQuestionBlock = {
type: "question"
id: string
questions: QuestionInfo[]
answers: string[][] // per-question array of selected labels
decidedAt: number
}
```
#### Why `blocked` instead of OpenCode's approach
OpenCode keeps the tool at `"running"` while permission is pending and fires a
separate `permission.asked` SSE event. The problems:
1. **No durable record** — permission decisions are ephemeral events, not
persisted on the tool part. Reload the page after the session ends and you
lose all permission history.
2. **Separate event types** — the app must handle `permission.asked`,
`permission.replied`, `question.asked`, `question.replied` as special
protocol events and merge them with tool state. More reducer branches.
3. **Ambiguous tool state** — a tool at `"running"` could mean "executing" or
"blocked waiting for the user". The app cannot distinguish without checking
the side-channel.
With `"blocked"`:
1. **Durable** — the permission request and decision live on the tool part
forever. Auditable.
2. **Same update path** — tool goes blocked → `message.part.updated`. Tool
unblocks → `message.part.updated`. No new event types. The app reducer
just handles tool state transitions.
3. **Unambiguous**`"running"` means executing, `"blocked"` means waiting
for user. The UI knows exactly what to render without checking anything else.
#### Remaining part types
```ts
type FilePart = PartBase & {
type: "file"
mime: string
filename?: string
url: string // "data:..." inline or ref to encrypted blob
source?: FilePartSource
}
type StepStartPart = PartBase & {
type: "step-start"
snapshot?: string // git commit hash
}
type StepFinishPart = PartBase & {
type: "step-finish"
reason: string // "stop" | "tool-calls" | "length"
snapshot?: string
cost: number
tokens: {
input: number; output: number; reasoning: number
cache: { read: number; write: number }
}
}
type SubtaskPart = PartBase & {
type: "subtask"
prompt: string
description: string
agent: string
model?: { providerID: string; modelID: string }
command?: string
}
type AgentPart = PartBase & {
type: "agent"
name: string
}
type SnapshotPart = PartBase & { type: "snapshot"; snapshot: string }
type PatchPart = PartBase & { type: "patch"; hash: string; files: string[] }
type CompactionPart = PartBase & {
type: "compaction"
auto: boolean
overflow?: boolean
}
type RetryPart = PartBase & {
type: "retry"
attempt: number
error: APIError
time: { created: number }
}
```
## Session record
```ts
type Session = {
id: string
projectID: string
directory: string
parentID?: string // set for child sessions (subagents)
title: string
time: {
created: number
updated: number
compacting?: number
}
permission?: PermissionRule[] // session-level rules
summary?: {
additions: number
deletions: number
files: number
diffs?: FileDiff[]
}
}
```
## SSE event types
Minimal set. No special types for permissions, questions, or todos.
| Event | When |
|---|---|
| `session.created` | New session (including child sessions) |
| `session.updated` | Title, summary, metadata changed |
| `session.status` | idle / busy / retry |
| `session.error` | Unrecoverable session error |
| `session.compacted` | Context compaction completed |
| `message.updated` | Message info created or final state changed (tokens, cost, finish) |
| `message.removed` | Message deleted |
| `message.part.updated` | Part created or state changed (**including tool blocked/unblocked**) |
| `message.part.delta` | Streaming text append (partID + field + delta string) |
| `message.part.removed` | Part removed |
| `todo.updated` | Todo list replaced (convenience for todo dock, not a tool concern) |
| `file.edited` | File on disk changed by agent |
| `file.watcher.updated` | External file change detected |
Things conspicuously absent from this list:
- ~~`permission.asked`~~ — tool part goes `blocked`, `message.part.updated` fires
- ~~`permission.replied`~~ — tool part goes `running`/`error`, `message.part.updated` fires
- ~~`question.asked`~~ — tool part goes `blocked`, `message.part.updated` fires
- ~~`question.replied`~~ — tool part goes `running`/`error`, `message.part.updated` fires
Push notifications (mobile badge, desktop toast) are triggered by whatever
state change is relevant. That's a notification-layer concern, not a protocol
event taxonomy concern.
## Full exchange example
User: *"Create hello.txt with 'hello world'."*
Session has edit permission rule forcing asks.
### Message 1 — User
```jsonc
{
"info": {
"id": "msg_01abc", "sessionID": "ses_01xyz", "role": "user",
"time": { "created": 1753120000000 },
"agent": "build",
"model": { "providerID": "anthropic", "modelID": "claude-sonnet-4-6" }
},
"parts": [
{ "id": "prt_001", "sessionID": "ses_01xyz", "messageID": "msg_01abc",
"type": "text",
"text": "Create hello.txt with 'hello world'." }
]
}
```
### Message 2 — Assistant (tool call, gets blocked, then completes)
```jsonc
{
"info": {
"id": "msg_02def", "sessionID": "ses_01xyz", "role": "assistant",
"time": { "created": 1753120001000, "completed": 1753120004000 },
"parentID": "msg_01abc",
"modelID": "claude-sonnet-4-6", "providerID": "anthropic",
"agent": "build",
"path": { "cwd": "/home/user/app", "root": "/home/user/app" },
"cost": 0.0087,
"tokens": { "input": 4200, "output": 340, "reasoning": 0,
"cache": { "read": 3800, "write": 400 } },
"finish": "tool-calls"
},
"parts": [
{ "id": "prt_010", "type": "step-start", "snapshot": "a1b2c3d4",
"sessionID": "ses_01xyz", "messageID": "msg_02def" },
{ "id": "prt_011", "type": "reasoning",
"text": "I'll create the file using writeFile...",
"time": { "start": 1753120001100, "end": 1753120001400 },
"sessionID": "ses_01xyz", "messageID": "msg_02def" },
{ "id": "prt_012", "type": "tool",
"callID": "call_abc123", "tool": "writeFile",
"sessionID": "ses_01xyz", "messageID": "msg_02def",
"state": {
"status": "completed",
"input": { "path": "hello.txt", "content": "hello world\n" },
"output": "Created hello.txt (12 bytes)",
"title": "writeFile hello.txt",
"metadata": {},
"time": { "start": 1753120001500, "end": 1753120003800 },
"block": {
"type": "permission",
"id": "per_001",
"permission": "edit",
"patterns": ["hello.txt"],
"metadata": {
"filepath": "hello.txt",
"files": [{ "relativePath": "hello.txt", "type": "add",
"after": "hello world\n", "additions": 1, "deletions": 0 }]
},
"decision": "once",
"decidedAt": 1753120002500
}
}
},
{ "id": "prt_013", "type": "step-finish", "reason": "tool-calls",
"snapshot": "e5f6g7h8", "cost": 0.0087,
"tokens": { "input": 4200, "output": 340, "reasoning": 0,
"cache": { "read": 3800, "write": 400 } },
"sessionID": "ses_01xyz", "messageID": "msg_02def" }
]
}
```
Note: when this message is **final / persisted**, the tool part shows
`status: "completed"` with the resolved `block` showing what happened. But
during the live exchange, the SSE stream saw the tool part transition through:
```
1. message.part.updated → tool, status: "pending"
2. message.part.updated → tool, status: "running"
3. message.part.updated → tool, status: "blocked", block: { type: "permission", ... }
← app renders permission prompt with diff preview
4. message.part.updated → tool, status: "running"
← user approved, tool resumes
5. message.part.updated → tool, status: "completed", block: { ..., decision: "once" }
```
No special event types. Just tool state transitions.
### Message 3 — Assistant (final text)
```jsonc
{
"info": {
"id": "msg_03ghi", "sessionID": "ses_01xyz", "role": "assistant",
"time": { "created": 1753120004100, "completed": 1753120004800 },
"parentID": "msg_01abc",
"modelID": "claude-sonnet-4-6", "providerID": "anthropic",
"agent": "build",
"path": { "cwd": "/home/user/app", "root": "/home/user/app" },
"cost": 0.0023,
"tokens": { "input": 4600, "output": 45, "reasoning": 0,
"cache": { "read": 4200, "write": 400 } },
"finish": "stop"
},
"parts": [
{ "id": "prt_020", "type": "step-start",
"sessionID": "ses_01xyz", "messageID": "msg_03ghi" },
{ "id": "prt_021", "type": "text",
"text": "Done. Created `hello.txt` with \"hello world\".",
"sessionID": "ses_01xyz", "messageID": "msg_03ghi" },
{ "id": "prt_022", "type": "step-finish", "reason": "stop",
"cost": 0.0023,
"tokens": { "input": 4600, "output": 45, "reasoning": 0,
"cache": { "read": 4200, "write": 400 } },
"sessionID": "ses_01xyz", "messageID": "msg_03ghi" }
]
}
```
## Question tool exchange example
Agent calls the `question` tool to ask the user something:
```
1. message.part.updated → tool "question", status: "pending"
2. message.part.updated → tool "question", status: "running"
3. message.part.updated → tool "question", status: "blocked",
block: { type: "question", id: "q_001",
questions: [{ question: "Which database?", header: "DB choice",
options: [{ label: "PostgreSQL", description: "..." },
{ label: "SQLite", description: "..." }] }] }
← app renders question UI
4. message.part.updated → tool "question", status: "completed",
block: { type: "question", ..., answers: [["PostgreSQL"]], decidedAt: ... },
output: "User answered: Which database? = PostgreSQL"
```
Same update path. Same reducer logic.
## Subagents
### Creation
Parent agent calls `task` tool → CLI creates child session:
```jsonc
{
"id": "ses_child_001",
"parentID": "ses_01xyz",
"title": "Find all API endpoints (@explore)",
"directory": "/home/user/app",
"permission": [
{ "permission": "edit", "pattern": "*", "action": "deny" },
{ "permission": "bash", "pattern": "*", "action": "deny" },
{ "permission": "task", "pattern": "*", "action": "deny" }
]
}
```
### Parent transcript records it
The `task` tool part on the parent assistant message:
```jsonc
{
"type": "tool",
"callID": "call_task_001",
"tool": "task",
"state": {
"status": "completed",
"input": { "description": "Find API endpoints", "prompt": "...",
"subagent_type": "explore" },
"output": "task_id: ses_child_001\n\n<task_result>\n...\n</task_result>",
"title": "task → explore",
"metadata": {
"sessionId": "ses_child_001",
"model": { "providerID": "anthropic", "modelID": "claude-haiku-4-5" }
},
"time": { "start": 1753120005000, "end": 1753120012000 }
}
}
```
### Child session
Has its own messages, parts, full transcript. Fetched via
`GET /session/:parentID/children` or `GET /session/:childID/message`.
### Permission constraining
Agent types define default permission overrides. Child cannot escalate beyond
parent. The merge is restrictive.
### UI
- Parent shows `task` tool part (collapsed, expandable)
- Clicking navigates to child session transcript
- `parentID` on session gives nesting
## Todos
`todowrite` is a normal tool → tool part in the transcript.
Inside execution, it writes to a separate todo store AND the tool output
contains the current list as JSON.
The `todo.updated` SSE event is the one side-channel we keep — it exists so
the UI can update a todo dock without scanning tool parts. The todo table is
the quick-read store for "what are current todos?"
```ts
type Todo = {
content: string
status: "pending" | "in_progress" | "completed" | "cancelled"
priority: "high" | "medium" | "low"
}
```
## Permission rules
Rules come from three sources, merged in order:
1. **Project config**`.happy/config.json` or equivalent
2. **Session creation** — passed when session is created
3. **Runtime approvals** — accumulated "always" decisions during the session
```ts
type PermissionRule = {
permission: string // "edit" | "bash" | "read" | "task" | ...
pattern: string // glob: "*", "*.ts", "src/**"
action: "allow" | "deny" | "ask"
}
```
Evaluation: check rules in order. `deny` → tool errors immediately.
`allow` → tool runs, no block. `ask` → tool goes `blocked`.
If the user says `"always"`, the patterns from `block.always` are added to
runtime rules. Any other currently-blocked tools matching those patterns
auto-unblock.
If the user says `"reject"`, the tool errors AND all other pending blocked
tools in the same session also error (cascade reject — same as OpenCode).
## Storage model
### Happy's constraint: encrypted storage
OpenCode stores plaintext rows in SQLite and patches them freely. Happy stores
opaque encrypted blobs.
We choose **patchable canonical messages**:
- Message IDs are stable
- When a tool part transitions (pending → blocked → completed), re-encrypt
the full message and store the update
- Sync sends the full updated encrypted message
- Refetch returns latest state without replay
- No append-only event log as primary storage
This matches Happy's existing message delivery model. The inner plaintext
shape changes; the storage/sync envelope does not.
### What does NOT change
- DB rows
- `seq` ordering
- `localId`
- v3 HTTP message APIs
- Socket.IO invalidation
- Encrypted blob format
## What moves out of agentState
Keep in agentState:
- Current mode
- Available models / modes
- Live session config
- Transient backend capabilities
Move to tool parts:
- Pending permission requests → tool `status: "blocked"`
- Completed permission decisions → tool `block` field
- Question prompts → tool `status: "blocked"`
- Question answers → tool `block` field
## Provider adapters
Every provider adapter normalizes into this exact format at the CLI boundary:
- Claude adapter → messages + parts
- Codex adapter → messages + parts
- OpenClaw adapter → messages + parts
- ACP runner → messages + parts
- Gemini adapter → messages + parts
The app sees one shape. Provider weirdness stays in CLI.
## App impact
The normalizer converges to: parse `UserMessage` + `AssistantMessage`, hydrate
parts, done.
Delete:
- Provider-specific `codex` parsing
- Provider-specific `acp` parsing
- `output` compatibility logic
- `role: "session"` handling
The reducer gets simpler:
- Tool lifecycle is one state machine on one part type
- Permissions are just tool state transitions — no separate merge
- Questions are just tool state transitions
- Grouping from `parentID` on sessions + tool metadata
## Migration plan
### Phase 1: add new schemas alongside existing
- Define `UserMessage`, `AssistantMessage`, `Part` types in CLI and app
- Keep legacy parsing
- Add compatibility path in reducer
- Support patching existing canonical messages
### Phase 2: migrate one provider end to end
Target: ACP runner or Codex (both have adapter boundaries and event streams).
### Phase 3: migrate permissions into tool state
- Tool goes `blocked` instead of emitting to agentState
- Keep agentState fallback temporarily
- Switch UI to render from tool part state
### Phase 4: migrate remaining providers
Claude, OpenClaw, Gemini.
### Phase 5: delete legacy parsing
Remove codex, acp, output, `role: "session"` parsing.
## Open questions
1. Should `block.metadata` for permissions always include the full diff, or
should large diffs be a separate encrypted blob reference?
2. Do we need a `tool-progress` part type for long-running tools (e.g. bash
streaming output), or is periodic patching of the running state enough?
3. Should provider-native IDs (e.g. OpenAI `call_...`) be stored on tool
parts for debugging, or fully discarded after mapping?
4. Exact Zod schemas and field naming TBD during implementation — this doc
specifies the shape and semantics, not final field names.
## What we are NOT doing
- ~~Special SSE event types for permissions~~ — tool state transitions
- ~~Special SSE event types for questions~~ — tool state transitions
- ~~Side-channel permission store~~ — it's on the tool part
- ~~Side-channel question store~~ — it's on the tool part
- ~~`role: "session"` wrapper~~ — dead
- ~~Nested `ev.t`~~ — dead
- ~~Raw delta replay as primary sync model~~ — patchable canonical messages
- ~~New "session protocol" umbrella~~ — it's just messages and parts
+178
View File
@@ -0,0 +1,178 @@
# Reliable HTTP Messages API (v3)
## Overview
Replace Socket.IO-based message read/write with simple HTTP endpoints optimized for CLI usage. The new v3 API provides:
- **Cursor-based message reading** using the existing `seq` field — fetch from start, then poll for new messages after last known seq
- **Batch message sending** — CLI buffers messages locally and sends them in a single HTTP POST
- **Guaranteed order** — seq is allocated atomically per session, messages always returned in seq order
- **Reliable delivery** — no messages lost; client can always catch up by polling with last known seq
This is server-side only. CLI client migration will happen separately. Existing Socket.IO message flow remains fully functional (backward compatibility). The plan is to replace Socket.IO with SSE later.
## Context (from discovery)
- **Message storage**: `SessionMessage` table with per-session `seq` (allocated via `allocateSessionSeq` in `storage/seq.ts`)
- **Deduplication**: `localId` field with `@@unique([sessionId, localId])` constraint
- **Current read endpoint**: `GET /v1/sessions/:id/messages` — limited to 150, no cursor, ordered by `createdAt desc`
- **Current write**: Socket.IO `message` event in `socket/sessionUpdateHandler.ts` — single message at a time with `AsyncLock`
- **Event broadcasting**: `eventRouter.emitUpdate()` sends `new-message` updates to Socket.IO clients
- **Existing cursor pattern**: `GET /v2/sessions` uses ID-based cursor — we'll follow a similar pattern but use `seq`
- **Existing batch pattern**: KV store `POST /v1/kv` does atomic batch mutations — good reference
## Development Approach
- **Testing approach**: Regular (code first, then tests)
- Complete each task fully before moving to the next
- Make small, focused changes
- **CRITICAL: every task MUST include new/updated tests**
- **CRITICAL: all tests must pass before starting next task**
- **CRITICAL: update this plan file when scope changes during implementation**
- Maintain backward compatibility with existing Socket.IO message flow
## API Design
### Read Messages: `GET /v3/sessions/:sessionId/messages`
**Query Parameters:**
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `after_seq` | number | 0 | Return messages with seq > after_seq |
| `limit` | number | 100 | Max messages to return (1-500) |
**Response:**
```json
{
"messages": [
{
"id": "cuid",
"seq": 1,
"content": { "t": "encrypted", "c": "base64..." },
"localId": "optional-dedup-id",
"createdAt": 1234567890,
"updatedAt": 1234567890
}
],
"hasMore": true
}
```
**Behavior:**
- Messages ordered by `seq ASC` (oldest first, natural reading order)
- Client stores the highest `seq` received, polls with `after_seq=<lastSeq>` to get new messages
- `hasMore=true` means there are more messages beyond the returned batch — fetch again with `after_seq` set to last message's seq
- Initial load: `after_seq=0` fetches from the very beginning
- Catch-up: `after_seq=<lastKnownSeq>` fetches only new messages
- Uses existing index `@@index([sessionId, seq])` for efficient queries
### Send Messages: `POST /v3/sessions/:sessionId/messages`
**Request Body:**
```json
{
"messages": [
{
"content": "base64-encrypted-content",
"localId": "client-generated-dedup-id"
}
]
}
```
**Constraints:**
- Max 100 messages per batch
- Each message must have a `localId` for deduplication
- `content` is the base64-encoded encrypted message (same format as Socket.IO `message` event)
**Response:**
```json
{
"messages": [
{
"id": "cuid",
"seq": 5,
"localId": "client-dedup-id",
"createdAt": 1234567890,
"updatedAt": 1234567890
}
]
}
```
**Behavior:**
- Messages are created atomically — all succeed or all fail
- `seq` numbers are allocated sequentially within the session
- Duplicate `localId` messages are skipped (idempotent) — their existing record is returned
- After persisting, emits `new-message` updates via Socket.IO eventRouter for backward compatibility
- Returns all messages (including deduplicated ones) with their seq numbers
## Progress Tracking
- Mark completed items with `[x]` immediately when done
- Add newly discovered tasks with prefix
- Document issues/blockers with ⚠️ prefix
## Implementation Steps
### Task 1: Add cursor-based message read endpoint
- [x] Create `GET /v3/sessions/:sessionId/messages` route in a new `v3SessionRoutes.ts` file
- [x] Add Zod schema for query params: `after_seq` (number, default 0), `limit` (number, 1-500, default 100)
- [x] Verify session belongs to authenticated user (same pattern as v1)
- [x] Query `SessionMessage` where `sessionId` and `seq > after_seq`, order by `seq ASC`, take `limit + 1`
- [x] Return messages array + `hasMore` boolean (based on whether limit+1 rows returned)
- [x] Register route in `api.ts`
- [x] Write tests for read endpoint: basic fetch, cursor pagination, empty session, limit bounds
- [x] Write tests for edge cases: invalid session, unauthorized access, after_seq beyond latest
- [x] Run tests — must pass before next task
### Task 2: Add batch message send endpoint
- [x] Add `POST /v3/sessions/:sessionId/messages` route in `v3SessionRoutes.ts`
- [x] Add Zod schema for body: `messages` array (max 100), each with `content` (string) and `localId` (string)
- [x] Verify session belongs to authenticated user
- [x] For each message in the batch: check if `localId` already exists (dedup), skip if so
- [x] Allocate seq numbers sequentially for new messages using `allocateSessionSeq`
- [x] Create all new messages in DB
- [x] Emit `new-message` updates via `eventRouter.emitUpdate()` for each new message (backward compat with Socket.IO clients)
- [x] Return all messages (new + deduplicated) with their seq/id/timestamps
- [x] Write tests for batch send: single message, multiple messages, deduplication via localId
- [x] Write tests for edge cases: empty batch, exceeds 100 limit, invalid session, partial dedup (some new, some existing)
- [x] Run tests — must pass before next task
### Task 3: Verify acceptance criteria
- [x] Verify cursor-based reading works end-to-end: fetch from seq 0, paginate, catch up
- [x] Verify batch sending works: messages get sequential seq numbers, dedup works
- [x] Verify backward compatibility: sent messages trigger Socket.IO `new-message` updates
- [x] Verify order guarantee: messages always returned in seq order
- [ ] Run full test suite
- [ ] Run linter — all issues must be fixed
⚠️ Full package test suite currently fails on an existing unrelated fixture issue: `sources/storage/processImage.spec.ts` expects `sources/storage/__testdata__/image.jpg` which is missing in this workspace.
⚠️ No lint script is defined in `packages/happy-server/package.json`, so linter execution is currently not available.
## Technical Details
### Seq Allocation for Batches
The existing `allocateSessionSeq` increments by 1. For batch sends, we need N sequential seq numbers. Two options:
1. **Call `allocateSessionSeq` N times** — simple, uses existing code, but N DB roundtrips
2. **New `allocateSessionSeqBatch(sessionId, count)` function** — single `UPDATE sessions SET seq = seq + N` returning the new seq, then assign `(newSeq - N + 1)` through `newSeq`
Option 2 is preferred — single DB roundtrip regardless of batch size.
### Deduplication Strategy
For batch sends with mixed new/existing messages:
1. Query all existing messages with matching `localId` values in a single query
2. Filter out already-existing messages
3. Only create new messages and allocate seq for them
4. Return combined results (existing + newly created) sorted by seq
### Database Index Usage
- Read endpoint uses existing `@@index([sessionId, seq])` — efficient range scan
- Dedup lookup uses existing `@@unique([sessionId, localId])` — efficient point lookups
## Post-Completion
**CLI Migration (separate effort):**
- Update `happy-agent` to use `POST /v3/sessions/:id/messages` instead of Socket.IO `message` event
- Update `happy-agent` to poll `GET /v3/sessions/:id/messages` for receiving messages
- Eventually replace polling with SSE for real-time delivery
**SSE Migration (future):**
- Add `GET /v3/sessions/:id/messages/stream` SSE endpoint
- Client connects with `Last-Event-ID` = last seq for catch-up
- Replaces polling for real-time message delivery
@@ -0,0 +1,258 @@
# Plan: Remove Profiles & Wizard, Move CLI Detection to Daemon
## Context
The profiles feature (AI Backend Profiles) is half-baked — it provides env var management per session via a complex sync/schema system, but it's barely used and adds significant complexity. The wizard (new session creation flow) is tightly coupled to profiles. Both should be ripped out.
Simultaneously, CLI availability detection currently happens on-demand from the app via RPC bash calls (`useCLIDetection` hook). This should move to the daemon, which detects availability at boot and re-checks every 20 seconds via the keep-alive, pushing results as part of `MachineMetadata`. The detection must be cross-platform (POSIX + Windows).
The new session creation screen UI will be implemented separately — this plan only covers the cleanup and data flow changes.
---
## Part 1: Move CLI Detection to Daemon (MachineMetadata)
### 1.1 Add `cliAvailability` to `MachineMetadata` schema
**File:** `packages/happy-cli/src/api/types.ts` (line 130)
Add to `MachineMetadataSchema`:
```typescript
cliAvailability: z.object({
claude: z.boolean(),
codex: z.boolean(),
gemini: z.boolean(),
openclaw: z.boolean(),
detectedAt: z.number(),
}).optional()
```
Using `.optional()` so older daemons without this field still parse fine.
### 1.2 Add matching field to app-side schema
**File:** `packages/happy-app/sources/sync/storageTypes.ts` (line 119)
Add same `cliAvailability` optional field to the app's `MachineMetadataSchema`.
### 1.3 Implement cross-platform CLI detection in the daemon
**File:** `packages/happy-cli/src/daemon/run.ts`
Add a `detectCLIAvailability()` function that:
- Checks `os.platform()` to pick POSIX vs Windows detection
- **POSIX:** Uses `child_process.execSync` with `command -v claude`, `command -v codex`, `command -v gemini`, and the OpenClaw triple-check (command + config file + env var) — same logic as current `useCLIDetection.ts` lines 66-72
- **Windows:** Uses PowerShell `Get-Command` checks — same logic as `useCLIDetection.ts` lines 74-81
- Returns `{ claude: boolean, codex: boolean, gemini: boolean, openclaw: boolean, detectedAt: number }`
- Wraps in try/catch — on failure, returns all `false` with current timestamp
### 1.4 Run detection at daemon boot
**File:** `packages/happy-cli/src/daemon/run.ts` (line 27-34)
Update `initialMachineMetadata` construction to call `detectCLIAvailability()` and include the result:
```typescript
export const initialMachineMetadata: MachineMetadata = {
host: os.hostname(),
platform: os.platform(),
happyCliVersion: packageJson.version,
homeDir: os.homedir(),
happyHomeDir: configuration.happyHomeDir,
happyLibDir: projectPath(),
cliAvailability: detectCLIAvailability(),
};
```
### 1.5 Re-detect every 20 seconds on keep-alive
**File:** `packages/happy-cli/src/api/apiMachine.ts` (line 299-312)
Modify `startKeepAlive()` to also run `detectCLIAvailability()` every 20 seconds. If the result differs from the last known state, call `updateMachineMetadata()` to push the change. This avoids unnecessary metadata updates when nothing changed.
```
startKeepAlive():
every 20s:
emit machine-alive (existing)
newAvailability = detectCLIAvailability()
if (newAvailability differs from lastKnownAvailability):
update machine metadata with new cliAvailability
lastKnownAvailability = newAvailability
```
The `detectCLIAvailability` function needs to be importable from apiMachine.ts — put it in a shared util like `packages/happy-cli/src/utils/detectCLI.ts`.
### 1.6 Delete `useCLIDetection` hook from app
**File:** `packages/happy-app/sources/hooks/useCLIDetection.ts`**DELETE**
The app now reads `machine.metadata.cliAvailability` directly from the machine record (already decrypted and available via `useMachine()`). No RPC bash call needed.
---
## Part 2: Remove Profiles Feature
### 2.1 Files to DELETE entirely
| File | Reason |
|------|--------|
| `packages/happy-app/sources/sync/profileUtils.ts` | Built-in profile definitions & docs |
| `packages/happy-app/sources/sync/profileSync.ts` | Profile sync service |
| `packages/happy-app/sources/components/ProfileEditForm.tsx` | Profile editor form |
| `packages/happy-app/sources/components/EnvironmentVariablesList.tsx` | Env var list component |
| `packages/happy-app/sources/components/EnvironmentVariableCard.tsx` | Env var card component |
| `packages/happy-app/sources/hooks/useEnvironmentVariables.ts` | Queries daemon env vars for profiles |
| `packages/happy-app/sources/hooks/envVarUtils.ts` | Env var substitution utils |
| `packages/happy-app/sources/hooks/useCLIDetection.ts` | Replaced by daemon-side detection |
| `packages/happy-app/sources/app/(app)/settings/profiles.tsx` | Profile settings page |
### 2.2 Files to EDIT — remove profile references
**`packages/happy-app/sources/sync/settings.ts`**
- Remove: `AIBackendProfileSchema`, `AnthropicConfigSchema`, `OpenAIConfigSchema`, `AzureOpenAIConfigSchema`, `TogetherAIConfigSchema`, `TmuxConfigSchema`, `EnvironmentVariableSchema`, `ProfileCompatibilitySchema`
- Remove: `getProfileEnvironmentVariables()`, `validateProfileForAgent()`
- Remove from `SettingsSchema`: `profiles` field, `lastUsedProfile` field
- Keep: `lastUsedAgent`, `lastUsedPermissionMode`, `lastUsedModelMode`, `recentMachinePaths` (still useful)
**`packages/happy-app/sources/components/AgentInput.tsx`**
- Remove: imports from `profileUtils` and `settings` (lines 25-26)
- Remove: `profileId` and `onProfileClick` props (lines 77-78)
- Remove: profile data computation (lines 342-351)
- Remove: profile selector button UI (lines 991-1022)
**`packages/happy-app/sources/components/SettingsView.tsx`**
- Remove: profiles settings Item (lines 325-330)
**`packages/happy-app/sources/sync/ops.ts`**
- Remove `environmentVariables` from `SpawnSessionOptions` interface entirely
- Remove `environmentVariables` from the RPC params type in `machineSpawnNewSession()`
- The daemon only uses its own process.env + auth tokens
**`packages/happy-cli/src/persistence.ts`**
- Remove: `AIBackendProfileSchema` duplicate, `validateProfileForAgent()`, `getProfileEnvironmentVariables()`, `readSettings()` profile-related code, `activeProfileId` handling
**`packages/happy-cli/src/daemon/run.ts`**
- Remove: `getProfileEnvironmentVariablesForAgent()` function (lines 37-65)
- Remove: Layer 2 profile env var logic in `spawnSession()` (lines 293-324) — simplify to just auth env + daemon's process.env
- The env merge becomes: `{ ...authEnv }` only, expanded against `process.env`
---
## Part 3: Remove Wizard
### 3.1 Files to DELETE entirely
| File | Reason |
|------|--------|
| `packages/happy-app/sources/app/(app)/new/index.tsx` | Main wizard page — will be replaced with new simpler screen |
| `packages/happy-app/sources/app/(app)/new/pick/machine.tsx` | Machine picker sub-screen (uses SearchableListSelector with favorites) |
| `packages/happy-app/sources/app/(app)/new/pick/path.tsx` | Path picker sub-screen (uses SearchableListSelector with favorites) |
| `packages/happy-app/sources/app/(app)/new/pick/profile-edit.tsx` | Profile edit sub-screen |
| `packages/happy-app/sources/components/NewSessionWizard.tsx` | Legacy wizard component |
| `packages/happy-app/sources/utils/tempDataStore.ts` | Temp data between wizard screens |
Note: `SearchableListSelector` component itself is NOT deleted — it's a generic reusable component. Only its wizard-specific usages (favorites, double-section layout) go away. The new session screen's machine/path pickers will use simpler UI (implemented separately).
### 3.2 Files to EDIT — remove wizard references
**`packages/happy-app/sources/app/(app)/_layout.tsx`**
- Remove: Stack.Screen entries for `new/index`, `new/pick/machine`, `new/pick/path`, `new/pick/profile-edit` (lines 300-327)
**`packages/happy-app/sources/components/MainView.tsx`** — keep `router.push('/new')` (route stays the same)
**`packages/happy-app/sources/components/SidebarView.tsx`** — keep `router.push('/new')`
**`packages/happy-app/sources/components/HomeHeader.tsx`** — keep `router.push('/new')`
**`packages/happy-app/sources/components/CommandPalette/CommandPaletteProvider.tsx`** — keep `router.push('/new')`
**`packages/happy-app/sources/components/EmptySessionsTablet.tsx`** — keep `router.push('/new')`
All navigation stays at `/new` — no route changes needed.
**`packages/happy-app/sources/sync/persistence.ts`**
- KEEP: `NewSessionDraft` type, `loadNewSessionDraft()`, `saveNewSessionDraft()`, `clearNewSessionDraft()` — useful for the new simpler session screen too (stores machine, path, agent, permissions)
**`packages/happy-app/sources/sync/settings.ts`**
- Remove: `useEnhancedSessionWizard` from settings
- Remove: `favoriteDirectories` — only used by wizard's SearchableListSelector
- Remove: `favoriteMachines` — only used by wizard's SearchableListSelector
### 3.3 Translations cleanup
Remove from ALL language files (`en.ts`, `ru.ts`, `pl.ts`, `es.ts`, `ca.ts`, `it.ts`, `pt.ts`, `ja.ts`, `zh-Hans.ts`):
- `profiles.*` section entirely
- `newSession.*` section entirely
- `settings.profiles` and `settings.profilesSubtitle` keys
- `settingsFeatures.enhancedSessionWizard*` keys
---
## Part 4: Placeholder for New Session Route
Since the new UI will be implemented separately, create a minimal placeholder:
**`packages/happy-app/sources/app/(app)/new/index.tsx`** — replace with stub that:
- Shows "New Session" header
- Has the `AgentInput` composer at the bottom (reuse existing component)
- Reads `lastUsedAgent`, `lastUsedPermissionMode`, `lastUsedModelMode` from settings for defaults
- Calls `machineSpawnNewSession()` with machineId + directory + agent
- This is a temporary bridge — the full UI from the mockup will be built later
---
## Data Flow: Before vs After
### BEFORE (Current)
```
APP (wizard) DAEMON
├─ User picks machine
├─ useCLIDetection() ──RPC bash──→ ├─ runs `command -v` checks
│ ← parses stdout ────────────── │
├─ User picks agent (filtered)
├─ User picks profile
├─ getProfileEnvironmentVariables()
├─ machineSpawnNewSession({
│ machineId, dir, agent,
│ environmentVariables })
│ ──RPC──────────────────────────→ ├─ receives env vars
│ ├─ merges: profileEnv + authEnv
│ ├─ expands ${VAR} refs
│ ├─ spawns agent with merged env
│ └─
```
### AFTER (New)
```
DAEMON BOOT
├─ detectCLIAvailability()
├─ include in MachineMetadata
├─ POST /v1/machines (or update)
DAEMON KEEP-ALIVE (every 20s)
├─ emit machine-alive
├─ re-detect CLI availability
├─ if changed → machine-update-metadata
APP (new session screen)
├─ Read machine.metadata.cliAvailability (already there, no RPC)
├─ User picks agent (filtered by availability)
├─ machineSpawnNewSession({
│ machineId, dir, agent })
│ ──RPC──────────────────────────→ DAEMON
│ ├─ auth env only (no profiles)
│ ├─ spawns with process.env + authEnv
│ └─
```
---
## Verification
1. **Typecheck:** `yarn typecheck` in `happy-app` and `happy-cli` — must pass with no errors
2. **Daemon boot:** Start daemon, verify `initialMachineMetadata` includes `cliAvailability` in logs
3. **Keep-alive re-detection:** Install/uninstall a CLI tool, verify metadata updates within 20s
4. **App reads availability:** Open app, select a machine, verify availability shows from metadata (no bash RPC)
5. **Session spawn:** Create session from app — verify it spawns without profile env vars, using daemon's process.env
6. **Cross-platform:** Test detection commands on macOS (POSIX) — Windows testing if available
7. **No orphaned data:** Verify old `profiles` key in MMKV is ignored (schema no longer reads it)
8. **Navigation:** Verify all FAB/sidebar/header buttons navigate to the new session route
+313
View File
@@ -0,0 +1,313 @@
# Add Anthropic Sandbox Runtime to CLI
## Overview
Integrate `@anthropic-ai/sandbox-runtime` into happy-cli to sandbox both **Claude Code** and **Codex** sessions with OS-level filesystem and network restrictions. The sandbox wraps agent subprocesses, enforcing configurable restrictions without requiring containers.
Key features:
- **`happy sandbox configure`** - Interactive CLI wizard (using `inquirer`) to set up sandbox rules
- **`happy sandbox status`** - Show current sandbox configuration
- **`happy sandbox disable`** - Turn off sandboxing
- **Automatic enforcement** - Once configured, sandbox wraps both Claude and Codex sessions by default (bypass with `--no-sandbox`)
- **Global config** - Stored in `~/.happy/settings.json` alongside existing settings
- **Dual agent support** - Same sandbox config applies to both Claude Code and Codex
## Context
- **Claude spawn point**: `packages/happy-cli/src/claude/claudeLocal.ts:241` - `spawn('node', [claudeCliPath, ...args], {env, ...})`
- **Codex spawn point**: `packages/happy-cli/src/codex/codexMcpClient.ts:107` - `StdioClientTransport({ command: 'codex', args: ['mcp-server'], env })` which internally calls `cross-spawn('codex', ['mcp-server'])`
- **Config storage**: `packages/happy-cli/src/persistence.ts` - Settings interface + Zod schemas + atomic `updateSettings()`
- **Command dispatch**: `packages/happy-cli/src/index.ts` - manual `if/else if` routing on `args[0]`
- **Existing command pattern**: `packages/happy-cli/src/commands/connect.ts` - exported `handleXxxCommand(args)` functions
- **Test pattern**: Co-located `.test.ts` files using vitest (e.g., `claudeLocal.test.ts`)
## Sandbox Runtime API
```typescript
import { SandboxManager, type SandboxRuntimeConfig } from '@anthropic-ai/sandbox-runtime'
const config: SandboxRuntimeConfig = {
network: {
allowedDomains: undefined,
deniedDomains: [],
},
filesystem: {
denyRead: ['~/.ssh', '~/.aws'],
allowWrite: ['.', '/tmp'],
denyWrite: ['.env'],
},
}
await SandboxManager.initialize(config)
const wrappedCmd = await SandboxManager.wrapWithSandbox('node script.js')
// wrappedCmd is a string with OS-level sandbox wrapping
spawn(wrappedCmd, { shell: true })
await SandboxManager.reset()
```
## Agent Integration Architecture
### Claude Code (direct spawn)
Claude is spawned directly via `spawn('node', [claudeCliPath, ...args])` in `claudeLocal.ts`. We wrap the full command with `SandboxManager.wrapWithSandbox()` and spawn with `shell: true`.
When sandbox is enabled, automatically add `--dangerously-skip-permissions` to Claude's args. The sandbox provides OS-level enforcement, so Claude's built-in permission prompts become redundant friction.
### Codex (MCP SDK spawn)
Codex spawns via MCP SDK's `StdioClientTransport`, which calls `cross-spawn('codex', ['mcp-server'])` internally with `shell: false`. Since we can't modify the SDK's spawn call, we:
1. Initialize `SandboxManager` before creating the transport
2. Get the wrapped command via `SandboxManager.wrapWithSandbox('codex mcp-server')`
3. Pass `command: 'sh'`, `args: ['-c', wrappedCommand]` to `StdioClientTransport` instead of `command: 'codex'`, `args: ['mcp-server']`
This way the MCP SDK spawns `sh -c "<sandbox-wrapped codex mcp-server>"`, which achieves the same OS-level sandboxing.
When sandbox is enabled, force Codex to `approval-policy: 'never'` and `sandbox: 'danger-full-access'`. The OS-level sandbox already enforces restrictions, so Codex's own permission checks become redundant.
### Permission bypass rationale
The sandbox provides a strict OS-level security boundary (filesystem + network). With these hard restrictions enforced at the OS level, the agents' built-in permission prompts are unnecessary - they can only operate within what the sandbox allows. This gives the user a seamless "full auto" experience while maintaining real security.
## Development Approach
- **Testing approach**: Regular (code first, then tests)
- Complete each task fully before moving to the next
- Make small, focused changes
- **CRITICAL: every task MUST include new/updated tests** for code changes in that task
- **CRITICAL: all tests must pass before starting next task**
- **CRITICAL: update this plan file when scope changes during implementation**
- Run tests after each change
## Testing Strategy
- **Unit tests**: Required for every task - Zod schema validation, config resolution, command argument parsing, sandbox config builder logic
- **Integration tests**: Sandbox wrapping in `claudeLocal.ts` and `codexMcpClient.ts` (mock `SandboxManager`)
## Progress Tracking
- Mark completed items with `[x]` immediately when done
- Add newly discovered tasks with + prefix
- Document issues/blockers with warning prefix
- Update plan if implementation deviates from original scope
## Implementation Steps
### Task 1: Add `@anthropic-ai/sandbox-runtime` and `inquirer` dependencies
- [x] Run `yarn add @anthropic-ai/sandbox-runtime inquirer` in `packages/happy-cli`
- [x] Run `yarn add -D @types/inquirer` in `packages/happy-cli`
- [x] Verify packages install and build succeeds
### Task 2: Define sandbox config Zod schema and persistence
- [x] Add `SandboxConfigSchema` to `persistence.ts` with the following shape:
```typescript
const SandboxConfigSchema = z.object({
enabled: z.boolean().default(false),
workspaceRoot: z.string().optional(), // e.g. "~/projects"
sessionIsolation: z.enum(['strict', 'workspace', 'custom']).default('workspace'),
// 'strict' = only session cwd, 'workspace' = full workspaceRoot, 'custom' = user-defined paths
customWritePaths: z.array(z.string()).default([]), // extra paths for 'custom' mode
denyReadPaths: z.array(z.string()).default(['~/.ssh', '~/.aws', '~/.gnupg']),
extraWritePaths: z.array(z.string()).default(['/tmp']), // always allowed beyond workspace
denyWritePaths: z.array(z.string()).default(['.env']), // denied even within allowed dirs
networkMode: z.enum(['blocked', 'allowed', 'custom']).default('allowed'),
allowedDomains: z.array(z.string()).default([]), // for 'custom' network mode
deniedDomains: z.array(z.string()).default([]), // for 'custom' network mode
allowLocalBinding: z.boolean().default(true), // for dev servers
})
```
- [x] Add `sandboxConfig?: z.infer<typeof SandboxConfigSchema>` to the `Settings` interface
- [x] Add sandbox field to `defaultSettings` (undefined by default)
- [x] Export `SandboxConfig` type and the schema for external use
- [x] Write tests for `SandboxConfigSchema` validation (valid configs, invalid configs, defaults)
- [x] Run tests - must pass before next task
### Task 3: Create sandbox config builder utility
- [x] Create `packages/happy-cli/src/sandbox/config.ts`
- [x] Implement `buildSandboxRuntimeConfig(sandboxConfig, sessionPath)` function that converts our `SandboxConfig` into `SandboxRuntimeConfig`:
- Resolves `~` in all paths
- For `sessionIsolation`:
- `'strict'` → `allowWrite: [sessionPath, ...extraWritePaths]`
- `'workspace'` → `allowWrite: [workspaceRoot || sessionPath, ...extraWritePaths]`
- `'custom'` → `allowWrite: [...customWritePaths, ...extraWritePaths]`
- For `networkMode`:
- `'blocked'` → `allowedDomains: []` (block all)
- `'allowed'` → `allowedDomains: undefined` (no network isolation)
- Also set `enableWeakerNetworkIsolation: true` to allow `com.apple.trustd.agent` on macOS, which Codex needs for stable TLS in seatbelt mode
- `'custom'` → use `allowedDomains` and `deniedDomains` from config
- Maps `denyReadPaths` → `filesystem.denyRead`
- Maps `denyWritePaths` → `filesystem.denyWrite`
- Maps `allowLocalBinding` → `network.allowLocalBinding`
- [x] Write tests for `buildSandboxRuntimeConfig` covering all isolation modes and network modes
- [x] Write tests for path resolution (tilde expansion, relative paths)
- [x] Run tests - must pass before next task
### Task 4: Create sandbox lifecycle manager
- [x] Create `packages/happy-cli/src/sandbox/manager.ts`
- [x] Implement `initializeSandbox(sandboxConfig, sessionPath)`:
- Builds runtime config via `buildSandboxRuntimeConfig()`
- Calls `SandboxManager.initialize(runtimeConfig)`
- Returns cleanup function that calls `SandboxManager.reset()`
- [x] Implement `wrapCommand(command)`:
- Calls `SandboxManager.wrapWithSandbox(command)`
- Returns the wrapped command string
- [x] Implement `wrapForMcpTransport(command, args)`:
- Calls `SandboxManager.wrapWithSandbox(command + ' ' + args.join(' '))`
- Returns `{ command: 'sh', args: ['-c', wrappedCommand] }` for use with `StdioClientTransport`
- [x] Write tests for lifecycle manager (mock `SandboxManager`)
- [x] Run tests - must pass before next task
### Task 5: Create `happy sandbox configure` interactive wizard
- [x] Create `packages/happy-cli/src/commands/sandbox.ts`
- [x] Implement `handleSandboxCommand(args: string[])` with subcommand dispatch (`configure`, `status`, `disable`, `help`)
- [x] Implement `handleSandboxConfigure()` using `inquirer` prompts:
1. **Workspace root**: `input` prompt - "Where is your workspace root? (e.g. ~/projects)" with default `~/projects`
2. **Session isolation**: `list` prompt - "How should file access be scoped per session?"
- `strict` - "Only the session directory (most restrictive)"
- `workspace` - "Full workspace root directory"
- `custom` - "Let me specify custom paths"
3. (If `custom`): `input` prompt - "Enter writable paths (comma-separated):"
4. **Deny read paths**: `checkbox` prompt - "Which sensitive directories should be blocked from reading?" with defaults checked: `~/.ssh`, `~/.aws`, `~/.gnupg`, plus option to add custom
5. **Extra write paths**: `input` prompt - "Additional writable directories beyond workspace (comma-separated):" with default `/tmp`
6. **Deny write paths**: `input` prompt - "Files/dirs to deny writing even within allowed areas (comma-separated):" with default `.env`
7. **Network mode**: `list` prompt - "How should network access be handled?"
- `allowed` - "Allow all network access (default)"
- `blocked` - "Block all network access (most secure)"
- `custom` - "Allow specific domains only"
8. (If `custom`): `input` prompt - "Enter allowed domains (comma-separated, supports wildcards like *.github.com):"
9. **Allow localhost**: `confirm` prompt - "Allow binding to localhost ports? (for dev servers)" with default `true`
10. Show summary of configuration, ask for confirmation
- [x] Save config via `updateSettings()` with `sandboxConfig: { enabled: true, ...answers }`
- [x] Print success message with note about `--no-sandbox` flag
- [x] Write tests for `handleSandboxCommand` argument routing (unit test the dispatch logic)
- [x] Run tests - must pass before next task
### Task 6: Implement `happy sandbox status` and `happy sandbox disable`
- [x] Implement `handleSandboxStatus()` - reads settings, prints formatted sandbox config or "not configured"
- [x] Implement `handleSandboxDisable()` - sets `sandboxConfig.enabled = false` via `updateSettings()`
- [x] Implement `handleSandboxHelp()` - prints usage information
- [x] Write tests for status output formatting and disable logic
- [x] Run tests - must pass before next task
### Task 7: Integrate sandbox into Claude subprocess spawn
- [x] Modify `claudeLocal.ts` to accept `sandboxConfig?: SandboxConfig` in opts
- [x] Before the `spawn()` call (around line 233), if `sandboxConfig` is present and `enabled`:
1. Call `initializeSandbox(sandboxConfig, opts.path)` to get cleanup function
2. Append `--dangerously-skip-permissions` to args (sandbox enforces security at OS level, so Claude's permission prompts are redundant)
3. Call `wrapCommand('node ' + claudeCliPath + ' ' + args.join(' '))` to get wrapped command
4. Replace `spawn('node', [claudeCliPath, ...args])` with `spawn(wrappedCommand, { shell: true, ... })`
5. Note: `shell: true` changes stdio behavior - keep `['inherit', 'inherit', 'inherit', 'pipe']` but verify fd3 pipe still works through shell
- [x] Add cleanup: call the cleanup function in the `finally` block after process exits
- [x] Update existing `claudeLocal.test.ts` to cover sandbox wrapping (mock `SandboxManager`)
- [x] Write tests for sandbox initialization, permission bypass, and cleanup lifecycle
- [x] Run tests - must pass before next task
### Task 8: Integrate sandbox into Codex subprocess spawn
- [x] Modify `codexMcpClient.ts` to accept `sandboxConfig?: SandboxConfig` in constructor or `connect()` method
- [x] In `connect()`, if `sandboxConfig` is present and `enabled`:
1. Call `initializeSandbox(sandboxConfig, process.cwd())` to get cleanup function
2. Call `wrapForMcpTransport('codex', [mcpCommand])` to get `{ command: 'sh', args: ['-c', wrappedCmd] }`
3. Use the wrapped command/args in `StdioClientTransport` instead of `command: 'codex', args: [mcpCommand]`
- [x] Add a `sandboxEnabled` flag on `CodexMcpClient` so `runCodex.ts` can check it
- [x] In `runCodex.ts`, when sandbox is enabled, force `approval-policy: 'never'` and `sandbox: 'danger-full-access'` in `startSession()` config (OS-level sandbox enforces security, so Codex's permission prompts are redundant)
- [x] Add cleanup method or handle in `disconnect()` to call `SandboxManager.reset()`
- [x] Write tests for Codex sandbox wrapping and permission bypass (mock `SandboxManager` and `StdioClientTransport`)
- [x] Run tests - must pass before next task
### Task 9: Thread sandbox config through both launch chains
- [x] **Claude chain**:
- In `claudeLocalLauncher.ts`: accept and pass through `sandboxConfig` to `claudeLocal()`
- In `runClaude.ts` / `loop.ts`: read `sandboxConfig` from settings, pass through to launcher
- [x] **Codex chain**:
- In `runCodex.ts`: read `sandboxConfig` from settings, pass to `CodexMcpClient` constructor
- [x] **CLI flags**:
- In `index.ts`: add `--no-sandbox` flag parsing (sets `options.noSandbox = true`)
- Apply `--no-sandbox` to both Claude and Codex flows
- [x] **Command registration**:
- Register `sandbox` command in `index.ts` command dispatch (alongside `auth`, `connect`, etc.)
- [x] Write tests for `--no-sandbox` flag parsing
- [x] Run tests - must pass before next task
### Task 10: Add `happy sandbox` to help text and polish
- [x] Add `happy sandbox` to the help text in `index.ts` (alongside `auth`, `connect`, `daemon`, etc.)
- [x] Add startup message when sandbox is active for both Claude and Codex (e.g., "Sandbox enabled: workspace=~/projects, network=allowed")
- [x] Handle errors gracefully: if `SandboxManager.initialize()` fails, warn user and continue without sandbox
- [x] Handle unsupported platforms (Windows): skip sandbox with warning
- [x] Run tests - must pass before next task
### Task 11: Verify acceptance criteria
- [x] Verify `happy sandbox configure` walks through all questions and saves config (automated command tests)
- [x] Verify `happy sandbox status` shows current config (automated command tests)
- [x] Verify `happy sandbox disable` turns off sandbox (automated command tests)
- [x] Verify Claude launches with sandbox wrapping when configured (claudeLocal sandbox tests)
- [x] Verify Claude gets `--dangerously-skip-permissions` auto-added when sandbox is active (claudeLocal sandbox tests)
- [x] Verify Codex launches with sandbox wrapping when configured (codexMcpClient sandbox tests)
- [x] Verify Codex gets `approval-policy: 'never'` and `sandbox: 'danger-full-access'` when sandbox is active (execution policy tests)
- [x] Verify `--no-sandbox` bypasses sandbox for both agents (and does NOT auto-add permission bypass flags) (flag parsing + fallback tests)
- [x] Verify unconfigured state doesn't affect either agent launch (existing + new non-sandbox tests)
- [x] Verify network defaults to "allowed" (unrestricted) (schema default tests)
- [x] Run full test suite (unit tests)
- [ ] Run linter - all issues must be fixed
- ⚠️ Lint blocker: `packages/happy-cli` has no `eslint.config.*` / `.eslintrc*`, so ESLint 9 cannot run in this package.
### Task 12: Update documentation
- [x] Update README.md if it documents CLI commands
- [x] Update help text to be comprehensive
## Technical Details
### Config resolution flow
```
Settings (persistence.ts)
→ sandboxConfig?: SandboxConfig
→ buildSandboxRuntimeConfig(config, sessionPath)
→ SandboxRuntimeConfig (from @anthropic-ai/sandbox-runtime)
→ SandboxManager.initialize(runtimeConfig)
→ SandboxManager.wrapWithSandbox(command)
```
### Claude launch chain modification
```
index.ts (parse --no-sandbox)
→ runClaude(credentials, options) // options.noSandbox
→ readSettings() → sandboxConfig
→ loop() → claudeLocalLauncher() → claudeLocal()
→ if sandbox enabled: initializeSandbox() + wrapCommand()
→ spawn(wrappedCommand, { shell: true })
→ finally: cleanup()
```
### Codex launch chain modification
```
index.ts (parse --no-sandbox for codex subcommand too)
→ runCodex({credentials, startedBy, noSandbox})
→ readSettings() → sandboxConfig
→ CodexMcpClient(sandboxConfig)
→ connect():
→ if sandbox enabled: initializeSandbox() + wrapForMcpTransport()
→ StdioClientTransport({ command: 'sh', args: ['-c', wrappedCmd] })
→ disconnect(): cleanup()
```
### Default sandbox preset (after configure)
```json
{
"enabled": true,
"workspaceRoot": "~/projects",
"sessionIsolation": "workspace",
"denyReadPaths": ["~/.ssh", "~/.aws", "~/.gnupg"],
"extraWritePaths": ["/tmp"],
"denyWritePaths": [".env"],
"networkMode": "allowed",
"allowedDomains": [],
"deniedDomains": [],
"allowLocalBinding": true
}
```
## Post-Completion
**Manual verification:**
- Test `happy sandbox configure` end-to-end on macOS
- Test that Claude Code sessions actually run inside sandbox (try reading `~/.ssh`)
- Test that Codex sessions actually run inside sandbox (try reading `~/.ssh`)
- Test that `--no-sandbox` flag works correctly for both agents
- Verify sandbox doesn't break Claude's PTY/stdin interaction
- Verify sandbox doesn't break Codex's MCP JSON-RPC over stdio
- Test with `shell: true` spawn mode doesn't cause issues with argument quoting
- Verify network is unrestricted by default (can reach any domain)
**Platform considerations:**
- macOS: Uses `sandbox-exec` with Seatbelt profiles (fully supported)
- Linux: Requires `bubblewrap` + `socat` (document prerequisites)
- Windows: Not supported by sandbox-runtime (skip gracefully)
+222
View File
@@ -0,0 +1,222 @@
# Session Protocol Implementation
## Overview
Implement `docs/session-protocol.md` as the new message format for Codex sessions in the CLI, and add client-side support in `happy-app` to parse, normalize, and render these messages alongside existing legacy formats (output, codex, acp).
**Key decisions from planning:**
- CLI (Codex only): Emit session-protocol events **instead of** current codex/acp format to the server
- App: Support **both** legacy and session-protocol formats (detect and handle both)
- Conversion happens on CLI level; client normalizes the flat event stream back into grouped structures (subagent nesting, turn grouping)
## Context
### Message flow today (Codex → App)
1. `runCodex.ts` receives MCP messages from Codex CLI
2. Calls `session.sendCodexMessage()` which wraps in `{ role: 'agent', content: { type: 'codex', data: body } }`
3. Encrypted and sent via WebSocket
4. App decrypts → `normalizeRawMessage()` in `typesRaw.ts``NormalizedMessage`
5. `reducer.ts` processes `NormalizedMessage[]``Message[]` for UI
### What changes
- **CLI**: Instead of `sendCodexMessage()`, use a new `sendSessionProtocolMessage()` that emits the 7 event types from session-protocol.md
- **App**: `typesRaw.ts` gets a new discriminated union branch `type: 'session'` in `rawAgentRecordSchema`, with `normalizeRawMessage()` converting session-protocol events to `NormalizedMessage`
- **Reducer**: Minimal changes — already handles `NormalizedMessage` correctly; just needs turn tracking awareness
### Files involved
**CLI (happy-cli):**
- `src/api/apiSession.ts` — new `sendSessionProtocolMessage()` method
- `src/codex/runCodex.ts` — convert from `sendCodexMessage()` to session-protocol events
- `src/codex/utils/reasoningProcessor.ts` — emit thinking events
**App (happy-app):**
- `sources/sync/typesRaw.ts` — new Zod schema for session-protocol envelope + events, new normalizer branch
- `sources/sync/reducer/reducer.ts` — turn tracking (optional, for grouping)
- `sources/sync/reducer/messageToEvent.ts` — handle session-protocol `turn-start`/`turn-end`
## Development Approach
- **Testing approach**: Regular (code first, then tests)
- Complete each task fully before moving to the next
- Make small, focused changes
- **CRITICAL: every task MUST include new/updated tests** for code changes in that task
- **CRITICAL: all tests must pass before starting next task**
- **CRITICAL: update this plan file when scope changes during implementation**
- Run tests after each change
- Maintain backward compatibility (legacy formats keep working)
## Testing Strategy
- **Unit tests**: required for every task
- Tests live colocated with source files (`.test.ts` suffix)
- Framework: Vitest
## Progress Tracking
- Mark completed items with `[x]` immediately when done
- Add newly discovered tasks with prefix
- Document issues/blockers with ⚠️ prefix
- ⚠️ `packages/happy-cli` and `packages/happy-app` do not define a `lint` script; verification used full test suites plus `yarn typecheck` in both packages.
- ⚠️ In app normalization for `content.type === 'session'`, `uuid` uses envelope `id` (not `turn`) to keep message identity unique while `invoke` handles sidechain linkage.
- ⚠️ `ReasoningProcessor` and `DiffProcessor` still emit legacy internal shapes; Codex now maps those outputs to session-protocol envelopes in `sessionProtocolMapper.ts` before sending.
## Implementation Steps
### Task 1: Define session-protocol types and Zod schemas (shared)
Create the TypeScript types and Zod schemas for all 7 session-protocol event types plus the envelope. These will be used by both CLI (for emitting) and app (for parsing).
- [x] Create `packages/happy-cli/src/sessionProtocol/types.ts` with:
- Envelope type: `{ id, time, role, turn?, invoke?, ev }`
- Event union type discriminated by `ev.t`: `text`, `tool-call-start`, `tool-call-end`, `file`, `photo`, `turn-start`, `turn-end`
- Each event type as a separate interface
- Zod schemas for validation
- [x] Export a helper `createEnvelope(role, ev, opts?)` that generates cuid2 id and timestamp
- [x] Write tests for Zod schema validation (valid events pass, invalid rejected)
- [x] Write tests for `createEnvelope` helper
- [x] Run tests — must pass before next task
### Task 2: Add `sendSessionProtocolMessage()` to `apiSession.ts`
Add a new send method that wraps session-protocol envelopes in the wire format.
- [x] Add `sendSessionProtocolMessage(envelope)` to `ApiSessionClient` in `packages/happy-cli/src/api/apiSession.ts`
- Wraps as `{ role: 'session', content: envelope }`
- Encrypts and sends via socket (same pattern as `sendCodexMessage`)
- [x] Write tests for the new method (verify envelope wrapping, encryption call)
- [x] Run tests — must pass before next task
### Task 3: Convert Codex message handler to emit session-protocol events
Replace `sendCodexMessage()` calls in `runCodex.ts` with session-protocol event emission.
- [x] Add turn tracking state to `runCodex.ts`:
- `currentTurnId: string | null` — set on `task_started`, cleared on `task_complete`/`turn_aborted`
- Emit `turn-start` event on `task_started`
- Emit `turn-end` event on `task_complete` / `turn_aborted`
- [x] Convert `agent_message``text` event with `turn` field
- [x] Convert `agent_reasoning` / `agent_reasoning_delta``text` event with `thinking: true`
- [x] Convert `exec_command_begin` / `exec_approval_request``tool-call-start` event
- `call`: use `call_id` from MCP message
- `name`: `CodexBash`
- `title`: short summary from command
- `description`: full command description
- `args`: input params
- [x] Convert `exec_command_end``tool-call-end` event
- [x] Convert `patch_apply_begin``tool-call-start` with name `CodexPatch`
- [x] Convert `patch_apply_end``tool-call-end`
- [x] Convert `token_count` → skip (no session-protocol equivalent, or emit as-is using sendCodexMessage for backwards compat)
- [x] Keep `task_started`/`task_complete`/`turn_aborted` sending via session events (sendSessionEvent) — these are lifecycle, not messages
- [x] Write tests for the conversion logic (mock session, verify correct event types emitted)
- [x] Run tests — must pass before next task
### Task 4: Update ReasoningProcessor to emit session-protocol events
The `ReasoningProcessor` currently calls `session.sendCodexMessage()` directly via callback. Update it.
- [x] Update the callback type in `ReasoningProcessor` constructor to accept session-protocol envelopes
- [x] Convert reasoning tool-call emissions to `tool-call-start` / `tool-call-end` session-protocol events
- [x] Convert reasoning message emissions to `text` events with `thinking: true`
- [x] Write tests for the updated processor output format
- [x] Run tests — must pass before next task
### Task 5: Add session-protocol parsing to `typesRaw.ts` (app)
Add a new `type: 'session'` branch to the raw record schema and update `normalizeRawMessage()`.
- [x] Add Zod schema for the session-protocol envelope in `typesRaw.ts`:
- `z.object({ type: z.literal('session'), data: sessionEnvelopeSchema })`
- The envelope schema validates `id`, `time`, `role`, `turn?`, `invoke?`, `ev` with discriminated union on `ev.t`
- [x] Add `'session'` to `rawAgentRecordSchema` discriminated union
- [x] Add normalization logic in `normalizeRawMessage()` for `raw.content.type === 'session'`:
- `ev.t === 'text'``NormalizedMessage` with `role: 'agent'`, content: `[{ type: 'text', text, uuid, parentUUID }]` (or `type: 'thinking'` if `ev.thinking`)
- `ev.t === 'tool-call-start'``NormalizedMessage` with content: `[{ type: 'tool-call', id: ev.call, name: ev.name, input: ev.args, description: ev.description }]`
- `ev.t === 'tool-call-end'``NormalizedMessage` with content: `[{ type: 'tool-result', tool_use_id: ev.call, content: null, is_error: false }]`
- `ev.t === 'turn-start'``NormalizedMessage` with `role: 'event'`, content: `{ type: 'message', message: 'Turn started' }` (or skip)
- `ev.t === 'turn-end'``NormalizedMessage` with `role: 'event'`, content: `{ type: 'ready' }` (triggers ready handling)
- `ev.t === 'file'` → map to tool-call for display
- `ev.t === 'file'` with `image` metadata → map to tool-call for display (or new message type later)
- [x] Handle `invoke` field: set `parentUUID` to the `invoke` value so sidechains work through existing tracer
- [x] Handle `turn` field: set `uuid` to `turn` value so grouping works
- [x] Write tests for each event type normalization (valid input → correct NormalizedMessage)
- [x] Write tests for invalid/malformed session events (graceful null return)
- [x] Run tests — must pass before next task
### Task 6: Update reducer for turn-start/turn-end awareness
The reducer already handles `NormalizedMessage` well. Minor updates for the new event semantics.
- [x] Ensure `turn-end` events with `{ type: 'ready' }` trigger `hasReadyEvent = true` (already works via existing code path)
- [x] Ensure `turn-start` events don't create visible messages (filter them out or make them no-op)
- [x] Ensure subagent messages (with `invoke``parentUUID`) flow through existing sidechain/tracer logic
- [x] Write tests verifying turn lifecycle events flow correctly through reducer
- [x] Write tests verifying subagent messages nest correctly under parent tool calls
- [x] Run tests — must pass before next task
### Task 7: Verify acceptance criteria
- [x] Verify Codex CLI emits session-protocol events for all message types
- [x] Verify app can parse and display session-protocol messages
- [x] Verify app still handles legacy formats (output, codex, acp) correctly
- [x] Verify subagent nesting works via invoke field
- [x] Verify turn lifecycle (turn-start/turn-end) works correctly
- [x] Run full test suite — `yarn test` in happy-cli, happy-app
- [x] Run linter — all issues must be fixed
### Task 8: [Final] Update documentation
- [x] Update `docs/session-protocol.md` if any deviations from spec were necessary
- [x] Add inline code comments for the session-protocol conversion path
## Technical Details
### Envelope structure (wire format)
```typescript
// What gets encrypted and sent over WebSocket
{
role: 'agent',
content: {
type: 'session', // NEW discriminator
data: { // session-protocol envelope
id: 'cuid2...',
time: 1739347200000,
role: 'agent',
turn: 'turn-id',
invoke: 'parent-call-id', // only for subagents
ev: { t: 'text', text: 'Hello' }
}
},
meta: { sentFrom: 'cli' }
}
```
### Event → NormalizedMessage mapping
| Session Protocol Event | NormalizedMessage role | NormalizedMessage content type |
|---|---|---|
| `text` | `agent` | `text` (or `thinking` if `ev.thinking`) |
| `tool-call-start` | `agent` | `tool-call` |
| `tool-call-end` | `agent` | `tool-result` |
| `turn-start` | `event` | `{ type: 'message', message: 'Turn started' }` |
| `turn-end` | `event` | `{ type: 'ready' }` |
| `file` | `agent` | `tool-call` (synthetic, for UI display) |
| `photo` | `agent` | `tool-call` (synthetic, for UI display) |
### Turn tracking in CLI
```
task_started → emit turn-start, set currentTurnId
agent_message → emit text with turn: currentTurnId
exec_command_begin → emit tool-call-start with turn: currentTurnId
exec_command_end → emit tool-call-end with turn: currentTurnId
task_complete → emit turn-end, clear currentTurnId
```
## Post-Completion
**Manual verification:**
- Test with real Codex session to verify messages display correctly in app
- Verify existing Claude Code sessions still display correctly (legacy format)
- Test abort/resume flow with session-protocol format
- Test permission flow with session-protocol format
@@ -0,0 +1,356 @@
# Session Protocol Unification v2 — Draft
Status: **DRAFT**
## Overview
This document captures the proposed unified session protocol event types, incorporating feedback on top of the original `session-protocol-unification.md` plan. The goal is the same — all agents emit a single envelope format, all legacy senders die — but the event type surface has been redesigned.
For the migration mechanics (phases, task lists, app-side cleanup), see the original plan (stashed as `session-protocol-unification.md`).
## Envelope (unchanged)
```json
{
"id": "<cuid2>",
"time": 1739347200000,
"role": "user" | "agent",
"turn": "<cuid2>",
"subagent": "<cuid2>",
"ev": { "t": "...", ... }
}
```
| Field | Type | Description |
|-------|------|-------------|
| `id` | cuid2 | Globally unique message identifier |
| `time` | number | Unix timestamp in milliseconds |
| `role` | `"user"` \| `"agent"` | Who produced this event |
| `turn` | cuid2? | Turn id established by `turn-start`. Required on all agent messages during a turn |
| `subagent` | cuid2? | Subagent identifier — matches `agentId` from `subagent-start` |
| `ev` | object | Event body, discriminated by `ev.t` |
## Event Types (14)
### Content
**`text`** — user or agent
```json
{ "t": "text", "text": "Hello, how can I help?", "thinking": true }
```
| Field | Type | Description |
|-------|------|-------------|
| `text` | string | Message text (markdown) |
| `thinking` | boolean? | Agent only. `true` for internal reasoning |
| `steer` | boolean? | User only. Mid-turn injection — picked up between LLM calls. Default user messages queue for next turn |
Client-side ordering note: steer messages are displayed immediately but the agent hasn't picked them up yet. The app pins them below current agent output until `turn-end` or until the agent emits output that follows the steer. No protocol-level ordering needed — client display logic handles this.
**`file`** — user or agent
```json
{ "t": "file", "ref": "upload_def", "name": "report.pdf", "size": 524288, "image": { "width": 800, "height": 600, "thumbhash": "..." } }
```
| Field | Type | Description |
|-------|------|-------------|
| `ref` | string | Server upload ID |
| `name` | string | Display filename |
| `size` | number | File size in bytes |
| `image` | object? | Optional image metadata |
| `image.width` | number | Image width in pixels |
| `image.height` | number | Image height in pixels |
| `image.thumbhash` | string | Base64-encoded ThumbHash for instant placeholder |
### Turn Lifecycle
**`turn-start`** — agent
```json
{ "t": "turn-start" }
```
Establishes the `turn` id on the envelope. Also serves as the "agent picked up your message" signal — the gap between user `text` (sent) and `turn-start` is the "queued" window.
**`turn-end`** — agent
```json
{ "t": "turn-end", "status": "completed", "usage": { "inputTokens": 2400, "outputTokens": 180, "cost": 0.012 } }
```
| Field | Type | Description |
|-------|------|-------------|
| `status` | `"completed"` \| `"failed"` \| `"cancelled"` | Final turn outcome |
| `usage` | object? | Token usage for this turn |
| `usage.inputTokens` | number | Input tokens consumed |
| `usage.outputTokens` | number | Output tokens generated |
| `usage.cost` | number | Cost in USD |
Replaces the ephemeral `usage-report` socket event. Also replaces the legacy `ready` signal — `turn-end` IS the ready signal.
### Tool Calls
**`tool-call-start`** — agent
```json
{ "t": "tool-call-start", "call": "tc_abc", "name": "grep", "title": "Searching for handleClick", "description": "Searching for `handleClick` in **src/**", "args": { "pattern": "handleClick" } }
```
| Field | Type | Description |
|-------|------|-------------|
| `call` | string | Tool call identifier, matched by `tool-call-end` |
| `name` | string | Tool name (lowercase, hyphenated) |
| `title` | string | Short summary (inline markdown) |
| `description` | string | Full description (inline markdown) |
| `args` | object | Tool input arguments |
**`tool-call-end`** — agent
```json
{ "t": "tool-call-end", "call": "tc_abc" }
```
### Service
**`service`** — agent
```json
{ "t": "service", "text": "Context window compacted" }
```
System/internal messages — reconnecting, context compacted, process errors. Not from the LLM.
### Subagent
**`subagent-start`** — agent
```json
{ "t": "subagent-start", "agentId": "sa_abc123", "title": "Research agent" }
```
| Field | Type | Description |
|-------|------|-------------|
| `agentId` | string | Subagent identifier — same value used in envelope `subagent` field on all messages this subagent produces |
| `title` | string? | Human-readable label |
**`subagent-stop`** — agent
```json
{ "t": "subagent-stop", "agentId": "sa_abc123" }
```
### Session Control
**`session-control-changed`** — user or agent
```json
{ "t": "session-control-changed", "clientType": "phone", "tokenId": "tok_abc123" }
```
| Field | Type | Description |
|-------|------|-------------|
| `clientType` | `"cli"` \| `"phone"` \| `"browser"` \| `"daemon"` | What took control |
| `tokenId` | string? | Auth token identifying the device/session. Used for push notification routing — don't send to phone if browser has control |
Controlling device is also written to session state (metadata) as a rollup of the stream.
### Permissions
**`permission-request`** — agent
```json
{
"t": "permission-request",
"call": "tc_abc",
"toolName": "bash",
"title": "Run `rm -rf node_modules`",
"description": "Delete node_modules directory",
"args": { "command": "rm -rf node_modules" }
}
```
Always during a turn. Appears between `tool-call-start` and `tool-call-end`. The tool does not execute until the user responds. RPC still delivers in real-time, but the message is the permanent record in the stream.
**`permission-response`** — user
```json
{ "t": "permission-response", "call": "tc_abc", "decision": "allow-once" }
```
Matches the `call` from the request.
### Abort
**`abort`** — user
```json
{ "t": "abort" }
```
Written to the stream when user presses stop. RPC still does real-time delivery. Agent responds with `turn-end { status: "cancelled" }`.
### Configuration
**`agent-configuration-changed`** — user or agent
```json
{ "t": "agent-configuration-changed", "model": "claude-sonnet-4-6", "thinkingLevel": "high" }
```
All fields optional — include only what changed:
| Field | Type | Description |
|-------|------|-------------|
| `permissionMode` | string? | `"default"` \| `"acceptEdits"` \| `"bypassPermissions"` \| `"plan"` |
| `model` | string? | Model identifier |
| `thinkingLevel` | string? | Thinking budget level |
| `sandbox` | boolean? | Sandbox enabled |
User sends this when changing config from the app. Agent sends this when entering plan mode, etc. The actual config mechanism stays in session metadata (optimistic concurrency) — this event is the audit trail in the stream so you can scroll back and see "model was switched to Sonnet here."
## Event Type Summary
| Category | `ev.t` | Role | New? |
|----------|--------|------|------|
| Content | `text` | user/agent | modified (added `steer`) |
| Content | `file` | user/agent | existing |
| Turn | `turn-start` | agent | existing |
| Turn | `turn-end` | agent | modified (added `usage`) |
| Tool calls | `tool-call-start` | agent | existing |
| Tool calls | `tool-call-end` | agent | existing |
| Service | `service` | agent | existing |
| Subagent | `subagent-start` | agent | renamed, added `agentId` |
| Subagent | `subagent-stop` | agent | renamed, added `agentId` |
| Session | `session-control-changed` | user/agent | new |
| Permissions | `permission-request` | agent | new |
| Permissions | `permission-response` | user | new |
| Abort | `abort` | user | new |
| Config | `agent-configuration-changed` | user/agent | new |
## Removed
| What | Why |
|------|-----|
| `ready` signal (`sendSessionEvent({ type: 'ready' })`) | Redundant with `turn-end` |
| `start` / `stop` event names | Renamed to `subagent-start` / `subagent-stop` |
| `machineId` on control-changed | Replaced by `tokenId` — auth token already identifies the device |
| Ephemeral `usage-report` socket event | Moved into `turn-end.usage` |
## Realistic Flow
User opens session on phone, agent runs on CLI, user steers mid-turn, switches model, aborts.
```
// CLI starts, takes control
← { id: "m1", time: 1000, role: "agent", ev: { t: "session-control-changed", clientType: "cli", tokenId: "tok_laptop" } }
// User sends prompt from phone
← { id: "m2", time: 2000, role: "user", ev: { t: "text", text: "Find all TODO comments and fix the critical ones" } }
// Agent picks it up
← { id: "m3", time: 2500, role: "agent", turn: "t1", ev: { t: "turn-start" } }
← { id: "m4", time: 2501, role: "agent", turn: "t1", ev: { t: "text", text: "I'll search for TODOs first." } }
← { id: "m5", time: 2502, role: "agent", turn: "t1", ev: { t: "tool-call-start", call: "c1", name: "grep", title: "Searching for TODO", description: "Searching for `TODO` in **src/**", args: { "pattern": "TODO", "path": "src/" } } }
← { id: "m6", time: 2503, role: "agent", turn: "t1", ev: { t: "tool-call-end", call: "c1" } }
← { id: "m7", time: 2504, role: "agent", turn: "t1", ev: { t: "text", text: "Found 12 TODOs. Starting with the auth module..." } }
// User steers mid-turn — agent hasn't seen this yet
← { id: "m8", time: 2600, role: "user", ev: { t: "text", text: "Skip auth, focus on the payment module instead", steer: true } }
// Agent continues current LLM call, then picks up steer
← { id: "m9", time: 2700, role: "agent", turn: "t1", ev: { t: "tool-call-start", call: "c2", name: "edit", title: "Editing **src/payment/checkout.ts**", description: "Fixing TODO in payment module", args: { "file": "src/payment/checkout.ts" } } }
// Agent needs permission for a destructive edit
← { id: "m10", time: 2701, role: "agent", turn: "t1", ev: { t: "permission-request", call: "c2", toolName: "edit", title: "Editing **src/payment/checkout.ts**", description: "Replacing TODO with retry logic", args: { "file": "src/payment/checkout.ts" } } }
// User approves
← { id: "m11", time: 3000, role: "user", ev: { t: "permission-response", call: "c2", decision: "allow-once" } }
// Tool executes
← { id: "m12", time: 3001, role: "agent", turn: "t1", ev: { t: "tool-call-end", call: "c2" } }
// Agent spawns a subagent to research the second TODO
← { id: "m13", time: 3002, role: "agent", turn: "t1", ev: { t: "subagent-start", agentId: "sa_research", title: "Payment research" } }
← { id: "m14", time: 3003, role: "agent", turn: "t1", subagent: "sa_research", ev: { t: "text", text: "Looking at payment retry patterns..." } }
← { id: "m15", time: 3004, role: "agent", turn: "t1", subagent: "sa_research", ev: { t: "tool-call-start", call: "c3", name: "grep", title: "Searching for retry", description: "Searching for retry patterns", args: { "pattern": "retry" } } }
← { id: "m16", time: 3005, role: "agent", turn: "t1", subagent: "sa_research", ev: { t: "tool-call-end", call: "c3" } }
← { id: "m17", time: 3006, role: "agent", turn: "t1", subagent: "sa_research", ev: { t: "text", text: "Found existing retry utility." } }
← { id: "m18", time: 3007, role: "agent", turn: "t1", ev: { t: "subagent-stop", agentId: "sa_research" } }
// Turn completes with usage
← { id: "m19", time: 3008, role: "agent", turn: "t1", ev: { t: "text", text: "Fixed 2 TODOs in the payment module." } }
← { id: "m20", time: 3009, role: "agent", turn: "t1", ev: { t: "turn-end", status: "completed", usage: { inputTokens: 4800, outputTokens: 620, cost: 0.024 } } }
// User switches model from phone
← { id: "m21", time: 5000, role: "user", ev: { t: "agent-configuration-changed", model: "claude-sonnet-4-6", thinkingLevel: "low" } }
// User sends next prompt
← { id: "m22", time: 5001, role: "user", ev: { t: "text", text: "Now run the tests" } }
← { id: "m23", time: 5100, role: "agent", turn: "t2", ev: { t: "turn-start" } }
← { id: "m24", time: 5101, role: "agent", turn: "t2", ev: { t: "tool-call-start", call: "c4", name: "bash", title: "Run `npm test`", description: "Running test suite", args: { "command": "npm test" } } }
// User aborts
← { id: "m25", time: 5200, role: "user", ev: { t: "abort" } }
← { id: "m26", time: 5201, role: "agent", turn: "t2", ev: { t: "tool-call-end", call: "c4" } }
← { id: "m27", time: 5202, role: "agent", turn: "t2", ev: { t: "turn-end", status: "cancelled" } }
// User opens session from browser — control shifts, push notifications reroute
← { id: "m28", time: 8000, role: "user", ev: { t: "session-control-changed", clientType: "browser", tokenId: "tok_browser" } }
```
## What Stays on Side Channels
| Thing | Mechanism | Why not in stream |
|-------|-----------|-------------------|
| Activity/presence | Ephemeral `session-alive` / `session-end` socket events | Too frequent, not worth persisting |
| Config source of truth | Session metadata with optimistic concurrency | `agent-configuration-changed` is the audit trail, metadata is the mechanism |
| Permission delivery | RPC for real-time, stream for audit | RPC needed for immediate response, stream needed for history |
| Abort delivery | RPC for real-time, stream for audit | Same pattern |
## Migration — What Needs to Change
### CLI legacy senders to remove
| Method | Current wire shape | Used by | Action |
|--------|--------------------|---------|--------|
| `sendAgentMessage(provider, body)` | `{ role: 'agent', content: { type: 'acp', provider, data } }` | `runGemini.ts` (~25 call sites) | Replace with `AcpSessionManager``sendSessionProtocolMessage()` |
| `sendCodexMessage(body)` | `{ role: 'agent', content: { type: 'codex', data } }` | Dead code — Codex already on envelopes | Delete |
| `sendSessionEvent(event)` | `{ role: 'agent', content: { type: 'event', data } }` | All runners: ready, errors, mode switch | Delete — `turn-end` replaces ready, `service` replaces error messages |
| `ACPMessageData` type | — | `apiSession.ts` | Delete with `sendAgentMessage()` |
| `ACPProvider` type | — | `apiSession.ts` | Delete with `sendAgentMessage()` |
| `MessageAdapter` class | — | `agent/adapters/MessageAdapter.ts` | Delete — unused once `sendAgentMessage()` is gone |
### CLI: Gemini migration
`runGemini.ts` is the only runner still on `sendAgentMessage()`. Wire in `AcpSessionManager` (already works for the generic ACP runner and OpenClaw) and route all message dispatch through it.
### CLI: user text format
The CLI already emits only the modern `role: 'session'` envelope for user text. The `ENABLE_SESSION_PROTOCOL_SEND` flag that was planned as a rollout toggle was never implemented in code — removed from docs.
### App legacy branches to remove (in `typesRaw.ts` rawAgentRecordSchema)
| Branch | `content.type` | Action |
|--------|----------------|--------|
| `acp` | `{ type: 'acp', provider, data }` | Remove after Gemini migration |
| `codex` | `{ type: 'codex', data }` | Remove — Codex already on envelopes |
| `event` | `{ type: 'event', data }` | Remove — replaced by `service` and `turn-end` |
| `output` | Raw Claude JSONL | Keep for historical message replay (oldest format, may exist in production databases) |
Also remove the hyphenated content normalization transforms (`normalizeToToolUse()` / `normalizeToToolResult()`) once no active clients send those formats.
### happy-wire schema updates
- Rename `start`/`stop``subagent-start`/`subagent-stop` with `agentId` field
- Add `usage` to `turn-end` schema
- Add new event types: `session-control-changed`, `agent-configuration-changed`, `permission-request`, `permission-response`, `abort`
- Add `steer` field to `text` event schema
## Open Questions
- **`steer` field naming**: `steer: true` vs `sendType: "steer"` vs `queuing: "immediate"` — bikeshed later
- **Permission auto-approval**: when a tool is auto-approved (from a previous `allow-always`), emit `permission-request` + `permission-response` to the stream for audit, or skip for noise reduction?
- **Subagent `agentId` format**: cuid2 like today, or human-readable like `sa_research`? Examples above use readable ids for clarity but production would be cuid2
- **`session-control-changed` and metadata rollup**: exact fields to mirror in session state TBD
+826
View File
@@ -0,0 +1,826 @@
# Session Protocol v2 Design
Status: **DRAFT — under review**
## Context
The current session protocol (`happy-wire/src/sessionProtocol.ts`) was designed to solve a real problem: three different message formats (`output`, `codex`, `acp`) hitting the app, each with different field names and tool call shapes. The v1 protocol unified them into a flat event stream with 7 event types, normalized once in the CLI.
**What v1 got right:**
- Flat event stream — no nesting, single `switch` in the client
- Provider-agnostic — no agent backend leaks into the protocol
- Upload-first media with thumbhash for instant image placeholders
- `invoke` field for subagent tracking (flat stream, grouped by client)
- Turn lifecycle (`turn-start` / `turn-end`)
- Separation of lifecycle events from content events
**What v1 got wrong:**
- Single-letter field names (`t`, `ev`, `call`) — hard to read for humans and AI, negligible bandwidth savings under encryption
- No permissions in the protocol — permissions use a separate agent state + RPC side-channel, invisible in the chat transcript
- `role: "session"` wrapper around inner `role: "user" | "agent"` — unnecessary indirection
- Nested `ev` object — adds a level of nesting for no benefit
**What v1 was missing:**
- Permission request/response as first-class messages (audit trail)
- Parent tracking for nested tool calls (beyond subagents)
- Message consumption / read receipts
Before investing more, we researched the protocol landscape.
## Protocol Landscape (March 2026)
### Relevant protocols
| Protocol | Wire Format | Scope | Permissions | Our relevance |
|---|---|---|---|---|
| **ACP (Zed/JetBrains)** | JSON-RPC 2.0 / stdio | Editor <-> Coding Agent | First-class `request_permission` | **HIGH** — closest to our use case |
| **Pi RPC (pi.dev)** | Custom JSONL / stdin | Host <-> Agent | Extension UI sub-protocol | **MEDIUM** — good immutable stream design |
| **MCP (Anthropic)** | JSON-RPC 2.0 | Host <-> Tool Server | Guidelines only | **LOW** — different layer (tools, not sessions) |
| **A2A v1.0 (Google)** | Protobuf / JSON-RPC+gRPC+HTTP | Agent <-> Agent | `INPUT_REQUIRED` / `AUTH_REQUIRED` states | **LOW** — agent-to-agent, not UI-to-agent |
| **AGNTCY ACP** | REST / OpenAPI | Client <-> Remote Agent | Interrupt/resume mechanism | **LOW** — REST-oriented, LangGraph-specific |
### Key takeaway
These protocols are **complementary layers**, not competitors:
- **MCP** = app-to-tool
- **ACP (Zed)** = editor-to-agent
- **A2A** = agent-to-agent
Happy sits in the **ACP layer** — we're a remote UI controlling coding agents. Zed's ACP is the closest match, but we have unique constraints (remote/encrypted transport, multiple agent backends, mobile UI).
### What we take from each
**From ACP (Zed):** Permission kinds (`allow-once`, `allow-always`, `reject-once`, `reject-always`). Tool call status tracking vocabulary. Session lifecycle patterns.
**From Pi (pi.dev):** Immutable append-only event stream. Separate start/end events (not mutable status updates). Clear distinction between LLM deciding to call a tool vs the tool actually executing.
**From v1 (our own):** Flat stream with `turn` grouping. Parent tracking via a field on each message. Upload-first media with thumbhash. The 7-event-type simplicity target.
## Design Principles
1. **Immutable append-only stream** — messages are never updated, only appended. Start/end are separate events. (Inspired by Pi)
2. **Human-readable field names** — no abbreviations (`type` not `t`, `callId` not `call`, `toolName` not `name`)
3. **Debuggable** — a developer reading raw JSON should immediately understand what each message is
4. **Flat discriminated unions**`type` field at the top level, not nested `ev.t`
5. **Permissions in the stream** — permission requests and responses are messages like anything else, creating a permanent audit trail
6. **Parent tracking** — any message can carry a `parentId` linking it to the tool call that spawned it, supporting subagents, nested tool calls, and future scripted pipelines
7. **Not married to any protocol** — we borrow concepts, not wire formats
8. **Encryption boundary unchanged** — server sees `{ c: "...", t: "encrypted" }`, inner format is our concern
## Architecture
```
┌─────────────────────────────────────────────────────┐
│ Server (transport) │
│ Sees: { c: "<encrypted>", t: "encrypted" } │
│ Unchanged — stores/relays opaque blobs │
└─────────────────────────────────────────────────────┘
decrypt/encrypt
┌─────────────────────────────────────────────────────┐
│ Inner envelope (this spec) │
│ { role, type, ... } │
│ Human-readable, immutable, append-only │
└─────────────────────────────────────────────────────┘
CLI mappers
┌─────────────────────────────────────────────────────┐
│ Provider output (Claude SDK, Codex MCP, ACP, etc.) │
│ Each provider has its own format │
│ Mappers convert to our inner envelope │
└─────────────────────────────────────────────────────┘
```
## Message Format
Every message is a JSON object with these common fields:
```typescript
type BaseMessage = {
id: string; // unique message id (cuid2)
time: number; // unix timestamp ms
role: "user" | "agent";
type: string; // discriminator — the event type
turn?: string; // turn id (required for agent messages during a turn)
parentId?: string; // parent tool call id — for nesting (subagents, nested tools, pipelines)
agentId?: string; // subagent identity — stable id for the subagent producing this message
};
```
### `parentId` — hierarchical nesting
Any message can carry `parentId` pointing to the `callId` of the tool call that spawned it. This replaces v1's `invoke` field.
Use cases:
- **Subagent messages**: a `Task` tool call spawns a subagent → all subagent messages carry `parentId: "<task call id>"`
- **Nested tool calls**: a subagent's tool calls carry `parentId` pointing to their parent tool call
- **Scripted pipelines**: step B runs inside step A → step B's messages carry `parentId: "<step A call id>"`
Nesting can be arbitrarily deep. Clients group/indent by walking the `parentId` chain.
### `agentId` — subagent identity
Subagents have their own identity beyond just being children of a tool call. When a tool call spawns a subagent, the subagent's messages carry both `parentId` (which tool call spawned it) and `agentId` (the subagent's own stable identifier).
This matters because:
- A subagent may produce messages across multiple tool calls within the same turn
- The client needs to attribute work to a specific subagent for display (title, collapse state, etc.)
- Future: subagent metadata (name, type, session id) can be looked up by `agentId`
**How this differs from the current system:** The current protocol uses `subagent` as a cuid2 grouping id that the CLI mapper generates. It has no richer identity — no name, no type, no metadata. The v2 `agentId` serves the same grouping purpose but is named to clearly indicate it identifies an agent, not just a parent relationship.
**How ACP (Zed) handles this:** ACP has no subagent concept — it's a single-agent protocol. Tool calls are flat.
**How Pi handles this:** Pi has no subagent concept either. Single agent.
**How A2A handles this:** A2A delegates to other agents via `SendMessage` creating child tasks with `reference_task_ids`. Agents have identity via Agent Cards. This is the closest parallel — but at a much heavier weight.
**Our approach:** Keep it lightweight. `agentId` is a string identifier. The CLI mapper generates it when a subagent is detected. Optional `agentTitle` on `tool-call-start` provides a human-readable label when the tool call spawns a subagent.
---
## Event Types
### 11 event types — one `switch(type)` in the client
| Type | Role | Purpose |
|---|---|---|
| `turn-start` | agent | Agent begins processing |
| `turn-end` | agent | Agent finishes processing |
| `text` | agent or user | Text content (markdown, thinking) |
| `tool-call-start` | agent | Agent begins a tool invocation |
| `tool-call-end` | agent | Tool invocation completes |
| `permission-request` | agent | Agent asks user for approval |
| `permission-response` | user | User responds to permission request |
| `photo` | agent or user | Image with thumbhash for instant placeholder |
| `video` | agent or user | Video with thumbhash + dimensions + duration |
| `file` | agent or user | Generic file attachment by reference |
| `service` | agent | Internal/system messages |
---
### `turn-start`
Marks the beginning of an agent turn (one prompt → response cycle).
```json
{
"id": "msg_abc123",
"time": 1710000000000,
"role": "agent",
"type": "turn-start",
"turn": "turn_xyz789"
}
```
### `turn-end`
Marks the end of an agent turn.
```json
{
"id": "msg_abc124",
"time": 1710000010000,
"role": "agent",
"type": "turn-end",
"turn": "turn_xyz789",
"status": "completed"
}
```
`status`: `"completed"` | `"failed"` | `"cancelled"`
### `text`
Text content. Works for both user prompts and agent output.
```json
{
"id": "msg_abc125",
"time": 1710000000100,
"role": "agent",
"type": "text",
"turn": "turn_xyz789",
"text": "I'll help you fix that bug.",
"thinking": false
}
```
`thinking`: `true` for reasoning/thinking tokens, `false` or omitted for visible output. User messages never set `thinking`.
### `tool-call-start`
Agent begins a tool invocation. This is the LLM's decision to invoke a tool — execution may not start until after permission is granted.
```json
{
"id": "msg_abc126",
"time": 1710000000200,
"role": "agent",
"type": "tool-call-start",
"turn": "turn_xyz789",
"callId": "call_001",
"toolName": "bash",
"title": "Run `ls -la`",
"description": "List files in current directory",
"args": { "command": "ls -la" }
}
```
| Field | Type | Description |
|---|---|---|
| `callId` | string | Unique tool call identifier, matched by `tool-call-end` |
| `toolName` | string | Tool name (e.g. `bash`, `edit`, `grep`) |
| `title` | string | Short human-readable summary (inline markdown) |
| `description` | string | Longer description (inline markdown) |
| `args` | object | Tool input arguments |
### `tool-call-end`
Tool invocation completes. Matches a prior `tool-call-start` by `callId`.
```json
{
"id": "msg_abc127",
"time": 1710000000500,
"role": "agent",
"type": "tool-call-end",
"turn": "turn_xyz789",
"callId": "call_001"
}
```
Optionally carries an `error` field on failure:
```json
{
"id": "msg_abc127",
"time": 1710000000500,
"role": "agent",
"type": "tool-call-end",
"turn": "turn_xyz789",
"callId": "call_001",
"error": "Command exited with code 1"
}
```
### `permission-request`
Agent requests permission to proceed with a tool call. This goes into the message stream (not a side-channel), creating a permanent audit trail. Modeled after ACP's `session/request_permission`.
```json
{
"id": "msg_abc128",
"time": 1710000000300,
"role": "agent",
"type": "permission-request",
"turn": "turn_xyz789",
"callId": "call_001",
"toolName": "bash",
"title": "Run `rm -rf node_modules`",
"description": "Delete node_modules directory",
"args": { "command": "rm -rf node_modules" },
"options": [
{ "id": "allow-once", "label": "Allow once", "kind": "allow-once" },
{ "id": "allow-session", "label": "Allow for session", "kind": "allow-always" },
{ "id": "deny", "label": "Deny", "kind": "reject-once" },
{ "id": "deny-always", "label": "Always deny", "kind": "reject-always" }
]
}
```
Permission kinds (from ACP): `"allow-once"` | `"allow-always"` | `"reject-once"` | `"reject-always"`
**Timing**: A `permission-request` appears in the stream between `tool-call-start` and `tool-call-end`. The tool does not execute until the user responds.
### `permission-response`
User responds to a permission request.
```json
{
"id": "msg_user002",
"time": 1710000000400,
"role": "user",
"type": "permission-response",
"callId": "call_001",
"optionId": "allow-once"
}
```
### `photo`
Image attachment. The image must be uploaded/encrypted first, then referenced. Includes thumbhash for instant placeholder rendering (from v1 design).
```json
{
"id": "msg_abc132",
"time": 1710000000600,
"role": "user",
"type": "photo",
"ref": "media/upload_abc123",
"thumbhash": "3OcRJYB4d3h/iIeHeEh3eIhw+j2w",
"width": 1920,
"height": 1080
}
```
| Field | Type | Description |
|---|---|---|
| `ref` | string | Server upload/media reference ID |
| `thumbhash` | string | Base64-encoded [ThumbHash](https://evanw.github.io/thumbhash/) for instant placeholder |
| `width` | number | Original width in pixels |
| `height` | number | Original height in pixels |
### `video`
Video attachment. The video must be uploaded/encrypted first, then referenced. Includes thumbhash for instant poster frame rendering. See `docs/plans/encrypted-media-v1.md` for the full media pipeline design.
```json
{
"id": "msg_abc134",
"time": 1710000000800,
"role": "agent",
"type": "video",
"turn": "turn_xyz789",
"ref": "media/upload_vid789",
"thumbhash": "3OcRJYB4d3h/iIeHeEh3eIhw+j2w",
"width": 1920,
"height": 1080,
"durationMs": 45000,
"mimeType": "video/mp4",
"size": 104857600
}
```
| Field | Type | Description |
|---|---|---|
| `ref` | string | Server upload/media reference ID |
| `thumbhash` | string | Base64-encoded [ThumbHash](https://evanw.github.io/thumbhash/) for poster frame placeholder |
| `width` | number | Video width in pixels |
| `height` | number | Video height in pixels |
| `durationMs` | number | Video duration in milliseconds |
| `mimeType` | string | MIME type (e.g. `video/mp4`) |
| `size` | number | File size in bytes |
**V1 playback model**: download entire encrypted blob, decrypt locally, write to temp file, hand to native player. See encrypted-media-v1.md for benchmarks and V2 streaming considerations.
### `file`
Generic file attachment. The file must be uploaded/encrypted first, then referenced. Use `photo` or `video` for media with visual preview support.
```json
{
"id": "msg_abc133",
"time": 1710000000700,
"role": "agent",
"type": "file",
"turn": "turn_xyz789",
"ref": "media/upload_def456",
"name": "report.pdf",
"size": 104857600,
"mimeType": "application/pdf"
}
```
| Field | Type | Description |
|---|---|---|
| `ref` | string | Server upload/media reference ID |
| `name` | string | Display filename |
| `size` | number | File size in bytes |
| `mimeType` | string | MIME type |
### `service`
Internal/system messages (not directly from the LLM). Context compaction, session metadata, etc.
```json
{
"id": "msg_abc131",
"time": 1710000000050,
"role": "agent",
"type": "service",
"turn": "turn_xyz789",
"text": "Context window compacted"
}
```
---
## Example Streams
### Simple tool call
```
← { id: "a1", time: 1000, role: "user", type: "text", text: "Find TODOs" }
← { id: "a2", time: 1001, role: "agent", type: "turn-start", turn: "t1" }
← { id: "a3", time: 1002, role: "agent", type: "text", turn: "t1", text: "Searching...", thinking: false }
← { id: "a4", time: 1003, role: "agent", type: "tool-call-start", turn: "t1", callId: "c1", toolName: "grep", title: "Searching for TODO", description: "Searching for `TODO` in project root", args: { "pattern": "TODO" } }
← { id: "a5", time: 1004, role: "agent", type: "tool-call-end", turn: "t1", callId: "c1" }
← { id: "a6", time: 1005, role: "agent", type: "text", turn: "t1", text: "Found 3 TODOs." }
← { id: "a7", time: 1006, role: "agent", type: "turn-end", turn: "t1", status: "completed" }
```
### Tool call with permission
```
← { id: "b1", time: 2000, role: "user", type: "text", text: "Delete node_modules" }
← { id: "b2", time: 2001, role: "agent", type: "turn-start", turn: "t2" }
← { id: "b3", time: 2002, role: "agent", type: "tool-call-start", turn: "t2", callId: "c2", toolName: "bash", title: "Run `rm -rf node_modules`", description: "Delete node_modules directory", args: { "command": "rm -rf node_modules" } }
← { id: "b4", time: 2003, role: "agent", type: "permission-request", turn: "t2", callId: "c2", toolName: "bash", title: "Run `rm -rf node_modules`", args: { "command": "rm -rf node_modules" }, options: [...] }
← { id: "b5", time: 2010, role: "user", type: "permission-response", callId: "c2", optionId: "allow-once" }
← { id: "b6", time: 2011, role: "agent", type: "tool-call-end", turn: "t2", callId: "c2" }
← { id: "b7", time: 2012, role: "agent", type: "text", turn: "t2", text: "Done. Deleted node_modules." }
← { id: "b8", time: 2013, role: "agent", type: "turn-end", turn: "t2", status: "completed" }
```
### Subagent (nested via parentId + agentId)
```
← { id: "c1", time: 3000, role: "agent", type: "tool-call-start", turn: "t1", callId: "task1", toolName: "task", title: "Exploring codebase", description: "Searching for auth implementations", args: { "prompt": "Find auth code" }, agentTitle: "Auth research" }
← { id: "c2", time: 3001, role: "agent", type: "text", turn: "t1", parentId: "task1", agentId: "agent_sub1", text: "Looking at src/auth/..." }
← { id: "c3", time: 3002, role: "agent", type: "tool-call-start", turn: "t1", parentId: "task1", agentId: "agent_sub1", callId: "c3", toolName: "grep", title: "Searching for login", description: "Searching for `login` in src/auth/", args: { "pattern": "login" } }
← { id: "c4", time: 3003, role: "agent", type: "tool-call-end", turn: "t1", parentId: "task1", agentId: "agent_sub1", callId: "c3" }
← { id: "c5", time: 3004, role: "agent", type: "text", turn: "t1", parentId: "task1", agentId: "agent_sub1", text: "Found auth handler." }
← { id: "c6", time: 3005, role: "agent", type: "tool-call-end", turn: "t1", callId: "task1" }
```
- `parentId: "task1"` — these messages are children of the `task1` tool call (nesting)
- `agentId: "agent_sub1"` — these messages come from a specific subagent (identity)
- `agentTitle: "Auth research"` on the `tool-call-start` — human-readable label for the subagent when it spawns
- The `tool-call-end` for `task1` has no `agentId` — it's the parent agent closing the tool call
Nesting can go deeper — a subagent's tool call can itself spawn another subagent with its own `agentId`.
### User sends a photo
```
← { id: "d1", time: 4000, role: "user", type: "photo", ref: "media/up_1", thumbhash: "3OcRJYB4d3h/iIeHeEh3eIhw+j2w", width: 800, height: 600 }
← { id: "d2", time: 4001, role: "user", type: "text", text: "What's in this screenshot?" }
```
---
## How Permissions Change
### Current system (v0 — side-channel)
```
agent needs permission
→ updateAgentState(requests[id]) ← ephemeral side-channel, not in transcript
→ push notification to phone
→ user taps approve in app
→ app sends RPC('permission', {...}) ← separate encrypted RPC, not in transcript
→ CLI resolves pending promise
→ updateAgentState(completedRequests[id])
```
**Problem**: permissions are invisible in the chat. You can't scroll back and see what was approved/denied and when.
### New system (v2 — in the stream)
```
agent needs permission
→ tool-call-start emitted to stream
→ permission-request emitted to stream ← visible, permanent record
→ push notification to phone
→ user taps approve in app
→ permission-response emitted to stream ← visible, permanent record
→ CLI resolves pending promise
→ tool-call-end emitted to stream
```
**Benefit**: the full permission lifecycle is part of the permanent transcript. The RPC side-channel can still exist for the actual real-time delivery mechanism, but the messages are also recorded in the stream for replay/audit.
---
## Full Type Definition (TypeScript)
```typescript
type MessageBase = {
id: string; // cuid2
time: number; // unix timestamp ms
turn?: string; // turn id
parentId?: string; // parent tool call id (for nesting)
agentId?: string; // subagent identity
};
// --- Agent messages ---
type AgentTurnStart = MessageBase & {
role: "agent";
type: "turn-start";
turn: string;
};
type AgentTurnEnd = MessageBase & {
role: "agent";
type: "turn-end";
turn: string;
status: "completed" | "failed" | "cancelled";
};
type AgentTextMessage = MessageBase & {
role: "agent";
type: "text";
turn: string;
text: string;
thinking?: boolean;
};
type AgentToolCallStart = MessageBase & {
role: "agent";
type: "tool-call-start";
turn: string;
callId: string;
toolName: string;
title: string;
description: string;
args: Record<string, unknown>;
agentTitle?: string; // human-readable label when this tool call spawns a subagent
};
type AgentToolCallEnd = MessageBase & {
role: "agent";
type: "tool-call-end";
turn: string;
callId: string;
error?: string;
};
type AgentPermissionRequest = MessageBase & {
role: "agent";
type: "permission-request";
turn: string;
callId: string;
toolName: string;
title: string;
description?: string;
args?: Record<string, unknown>;
options: Array<{
id: string;
label: string;
kind: "allow-once" | "allow-always" | "reject-once" | "reject-always";
}>;
};
type AgentPhotoMessage = MessageBase & {
role: "agent";
type: "photo";
turn: string;
ref: string;
thumbhash: string;
width: number;
height: number;
};
type AgentVideoMessage = MessageBase & {
role: "agent";
type: "video";
turn: string;
ref: string;
thumbhash: string;
width: number;
height: number;
durationMs: number;
mimeType: string;
size: number;
};
type AgentFileMessage = MessageBase & {
role: "agent";
type: "file";
turn: string;
ref: string;
name: string;
size: number;
mimeType: string;
};
type AgentServiceMessage = MessageBase & {
role: "agent";
type: "service";
turn?: string;
text: string;
};
// --- User messages ---
type UserTextMessage = MessageBase & {
role: "user";
type: "text";
text: string;
};
type UserPermissionResponse = MessageBase & {
role: "user";
type: "permission-response";
callId: string;
optionId: string;
};
type UserPhotoMessage = MessageBase & {
role: "user";
type: "photo";
ref: string;
thumbhash: string;
width: number;
height: number;
};
type UserVideoMessage = MessageBase & {
role: "user";
type: "video";
ref: string;
thumbhash: string;
width: number;
height: number;
durationMs: number;
mimeType: string;
size: number;
};
type UserFileMessage = MessageBase & {
role: "user";
type: "file";
ref: string;
name: string;
size: number;
mimeType: string;
};
// --- Unions ---
type AgentMessage =
| AgentTurnStart
| AgentTurnEnd
| AgentTextMessage
| AgentToolCallStart
| AgentToolCallEnd
| AgentPermissionRequest
| AgentPhotoMessage
| AgentVideoMessage
| AgentFileMessage
| AgentServiceMessage;
type UserMessage =
| UserTextMessage
| UserPermissionResponse
| UserPhotoMessage
| UserVideoMessage
| UserFileMessage;
type SessionMessage = AgentMessage | UserMessage;
```
---
## What Stays the Same
- **Outer encrypted envelope**: `{ c: "<base64>", t: "encrypted" }` — server never sees content
- **WebSocket transport**: Socket.IO for real-time, REST for message fetch
- **Update types**: `new-message`, `update-session`, `update-machine` — unchanged
- **Message storage**: server stores opaque encrypted blobs, same as today
- **`messages.ts` types**: `SessionMessageContent`, `SessionMessage`, `Update*` schemas — unchanged
- **RPC mechanism**: still used for real-time permission delivery (and other RPCs), but permission messages are also written to the stream
## What Changes
| Before (v1) | After (v2) | Rationale |
|---|---|---|
| `ev.t` (single letter) | `type` (full word) | Human/AI readability |
| `ev.text` | `text` | Flat, no nesting |
| `ev.call` | `callId` | Descriptive |
| `ev.name` | `toolName` | Descriptive |
| Nested `ev` object | Flat top-level fields | One less level of nesting |
| `role: "session"` wrapper | `role: "agent"` / `role: "user"` directly | No unnecessary indirection |
| Single `tool-call` with mutable `status` | Separate `tool-call-start` / `tool-call-end` | Immutable append-only stream |
| `invoke` field (subagent) | `parentId` + `agentId` fields | `parentId` for nesting, `agentId` for subagent identity |
| No permissions in protocol | `permission-request` / `permission-response` | Audit trail, visible in transcript |
| No media types | `photo` + `video` (with thumbhash) + `file` | First-class media — aligns with encrypted-media-v1.md plan |
| Permissions via agent state + RPC only | Permissions in stream + RPC for delivery | Best of both — permanent record + real-time |
## Design Rules
1. **Immutable stream** — messages are never updated, only appended
2. **Upload-first** — files and photos are uploaded/encrypted to the server, then referenced by `ref`
3. **Every message has identity**`id` (cuid2) + `time` (ms) on every message
4. **11 event types** — simple `switch(type)` in any client
5. **Provider-agnostic** — no agent backend leaks into the protocol
6. **Consistent naming** — all `kebab-case` for types, `camelCase` for fields
7. **Inline markdown**`title` and `description` support `` `code` ``, **bold**, *italic*, [links]
8. **Parent chain**`parentId` enables arbitrary nesting without separate lifecycle events per nesting level
## Migration Path
**Key fact: v1 was never published to any CLI release.** Production CLIs (0.13.0) use the legacy `role: 'agent'` / `role: 'user'` format. v1 only ran in dev environments. This means we have zero backward compatibility obligations for v1 — we can replace it entirely.
### Phase 1: Define v2 types, delete v1
- Replace `sessionProtocol.ts` with `sessionProtocolV2.ts` containing Zod schemas matching the types above
- No need to keep v1 types — they were never shipped
- Update `happy-wire/src/index.ts` to export v2
### Phase 2: Update CLI mappers
- Rewrite `claude/utils/sessionProtocolMapper.ts` to emit v2 format
- Rewrite `codex/utils/sessionProtocolMapper.ts` to emit v2 format
- Rewrite `agent/acp/AcpSessionManager.ts` to emit v2 format
- Add permission-request/response messages to the stream alongside existing RPC flow
### Phase 3: Update app normalization
- Update `typesRaw.ts` to accept v2 inner envelopes
- v2 normalization should be simpler — flatter structure, less transformation
- Keep legacy (`role: 'agent'` / `role: 'user'`) normalization for production CLIs still in the wild
- Can drop v1 normalization entirely (it was only used in dev)
### Phase 4: Permission migration
- App renders permission UI from stream messages instead of (or in addition to) agent state
- RPC still used for real-time delivery, but the message is the source of truth
- Eventually deprecate agent state `requests` / `completedRequests`
## Open Questions
- **Versioning**: should messages carry a `version` field, or do we detect format by shape? (Leaning toward shape detection — the `type` field values are unique enough)
- **Plan messages**: ACP has a `plan` update type (prioritized entries). Do we want this? Could be useful for the manager/conductor workflow.
- **Message consumption**: need read receipts at the protocol level? (See backlog — "message consumption visibility")
- **Streaming deltas vs complete messages**: Pi streams text deltas. We currently send complete text blocks. Should we support deltas for lower latency? (Probably not yet — keep it simple)
- **Permission auto-approval**: when a tool is auto-approved (e.g. `allow-always` from a previous decision), should we still emit `permission-request` + `permission-response` to the stream for the audit trail? Or skip them for noise reduction?
### Attachments as parts vs separate messages — NEEDS DESIGN
The current design models `photo`, `video`, and `file` as standalone messages in the stream. This works fine for agent output (agent produces media, emits a message). But it's awkward for **user input with attachments**:
- User sends "what's in this screenshot?" + an image — that's conceptually ONE message with TWO parts (text + photo)
- If sent as two separate messages (`photo` then `text`), there's no guarantee they arrive/render together
- The agent may see the text before the image, or vice versa
- Batching separate messages atomically is annoying at the transport level
**How others handle this:**
- **Claude API**: messages have `content: Array<TextBlock | ImageBlock | ...>` — multi-part by design
- **A2A**: messages have `parts: Array<Part>` where each part can be text, file, or structured data
- **ACP (Zed)**: prompts have `prompt: Array<TextContent | ResourceContent>` — multi-part
- **Pi**: user messages have `attachments: Array<Attachment>` alongside the text content
- **MCP**: tool results have `content: Array<TextContent | ImageContent | ...>` — multi-part
Every protocol uses a **parts/content array** for this. Our flat "one message = one thing" model doesn't handle "text + attachment sent together" well.
**Options to consider:**
1. **Add a `parts` array** — a user message can carry `parts: [{ type: "text", text: "..." }, { type: "photo", ref: "...", ... }]`. This is the Claude/A2A approach. Clean but means user messages become structurally different from the flat event stream.
2. **Add a `groupId` field** — messages that should be treated as one atomic input share a `groupId`. Transport batches them. Keeps the flat stream but adds coordination complexity.
3. **Keep standalone messages, add ordering guarantees** — the transport ensures messages from the same sender in quick succession are delivered in order. The app groups consecutive user messages visually. Simplest but weakest guarantee.
4. **Hybrid** — agent messages stay flat (one event per message), user messages get a `parts` array. Different shapes for different roles. Ugly but pragmatic.
**Also relevant:** we currently only have **server-hosted media** (`ref` pointing to encrypted upload). We'll want **machine-native files** soon (files on the remote machine, referenced by path). And eventually **app-uploaded files** (user attaches from phone/browser). These are three different `ref` schemes that the `file`/`photo`/`video` types need to support — the `ref` field will need to distinguish between `media/<id>` (our uploads), `machine-file://<path>` (remote machine), etc.
### Evidence from Claude Code session logs
Analysis of the current session's JSONL log (grouped by `message.id` to reconstruct actual API messages):
**Assistant messages are multi-block:**
```
63x tool_use (single tool call)
46x text (just text)
26x text + tool_use (text then tool call)
15x tool_use + tool_use (2 parallel tool calls)
5x text + tool_use + tool_use (text then 2 parallel calls)
3x tool_use × 3 (3 parallel calls)
1x thinking + text + tool_use × 5 (thinking, text, 5 parallel calls)
```
**User/tool-result messages are heavily batched:**
```
4x tool_result × 7
3x tool_result × 4
3x tool_result × 8
2x tool_result × 10
1x tool_result × 18
1x tool_result + text + tool_result (results interleaved with injected text)
```
Claude Code **streams each block as a separate JSONL entry**, but the actual API message groups them by `message.id`. When Claude requests 3 parallel tool calls, that's ONE message with 3 `tool_use` blocks. The results come back as ONE message with 3 `tool_result` blocks.
**Implication:** Our flat "one event per message" model **loses this batching information**. Three separate `tool-call-start` events don't convey that they were requested as a parallel batch. This matters for:
- Display (the UI could show parallel calls side-by-side)
- Semantics (the agent intended these as a group, not sequential)
- User input (text + attachment is one atomic user intent)
This is another argument for the `parts` approach, at least for some message types. But it also suggests a lighter alternative: a `batchId` field that groups events without changing the flat structure.
**Decision: deferred.** This needs more thought. For now, keep the flat model — it works for the streaming display use case. Revisit when user-side attachments ship or when parallel tool call display matters.
## References
- [ACP (Zed) spec](https://agentclientprotocol.com/overview/introduction)
- [ACP Registry](https://zed.dev/acp)
- [Pi coding agent RPC](https://github.com/badlogic/pi-mono)
- [MCP spec (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25)
- [A2A v1.0.0 (2026-03-12)](https://github.com/a2aproject/A2A)
- [AGNTCY Agent Connect Protocol](https://github.com/agntcy/acp-spec)
- [Linux Foundation AAIF](https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation)
- [Original v1 session protocol spec](../session-protocol.md) — by Steve, Feb 2026

Some files were not shown because too many files have changed in this diff Show More