* fix(hub,cli): four hub-restart-cascade cleanup bugs (#913#914#916#919)
These four contained bugs were uncovered by a 2026-06-15 hub-restart
incident where `hapi-restart-hub` SIGTERMed 23 cursor ACP sessions.
Each fix lands independently of the architectural #915 (hub-restart
cascade-archive) and the hypothesis-pending #917 (reopen creates dead
session); audit-trail correctness and idempotency wins stand on their
own.
Fresh ACP sessions could be SIGTERMed during the async `update-metadata`
ACK round-trip, stranding the on-disk ACP store with no DB handle. Add
`ApiSessionClient.flushMetadata()` and await it after `onSessionFoundWithProtocol`
on the fresh-session branch. Resume-path pre-registration (PR #834) is
unchanged.
Hub-restart-cascade SIGTERMs went through the same path as web-UI
Archive clicks, both writing archiveReason='User terminated'. New
default is 'Hub restart'; the KillSession RPC handler (the
authoritative user-archive signal) now explicitly stamps
'User terminated' before cleanupAndExit. SIGINT (local-terminal Ctrl-C)
keeps the 'User terminated' label too.
`rpcGateway.killSession` threw a generic Error when no target socket
was registered, and the archive route surfaced that as 500. Add typed
`RpcTargetMissingError`, narrow on it in `syncEngine.archiveSession`,
fall back to a hub-side `markSessionArchivedFromHub` write so
lifecycleState still flips to 'archived'. Drop the requireActive
guard on the route and 2xx-noop for already-archived rows.
without refresh, producing forever-409 on rename/reopen until an
unrelated event triggered a cache refresh. `renameSession`,
`clearSessionArchiveMetadata`, `restoreSessionArchiveMetadata` now
retry-with-refresh (5 attempts, then throw) mirroring the existing
good pattern in `mergeSessions`.
Refs tiann/hapi#913
Refs tiann/hapi#914
Refs tiann/hapi#916
Refs tiann/hapi#919
AI disclosure: implementation by Claude Sonnet 4.5 (Cursor agent peer)
under operator supervision. Issue triage by a sibling discovery agent.
Per CONTRIBUTING.md AI-assisted contributions policy.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): runner-spawned children use 'Stopped by runner' as default archive reason
Addresses bot review of #923: with the #914 default-archiveReason flip to
'Hub restart', runner-driven SIGTERM paths (`hapi runner stop-session`,
webhook-timeout cleanup at run.ts:587, orphan-cleanup at run.ts:267) all
mislabel as 'Hub restart' which is also inaccurate audit-trail noise.
Smallest defensible change: parameterise the lifecycle default via
HAPI_DEFAULT_ARCHIVE_REASON env, and have the runner set
'Stopped by runner' on spawn. Terminal-launched sessions (no runner
parent, no env var) still default to 'Hub restart' since hub-restart
cascade documented at #915 is the most plausible SIGTERM source for
those. Explicit overrides via setArchiveReason (KillSession RPC, SIGINT
Ctrl-C, markCrash uncaught exception) still win.
Two new unit tests cover the env-var default and the override
precedence.
Refs tiann/hapi#914.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): markSessionArchivedFromHub surfaces persistence failures as 5xx
Addresses second-round bot review of #923 (Major): `markSessionArchivedFromHub`
silently returned on DB write errors and on exhausted version-retry
attempts, which would let `/archive` claim 200 OK while the row stayed
unarchived. That regresses the #916 acceptance criterion that non-RPC
errors during archive must still propagate as 5xx.
Both fall-through paths now throw, matching the contract of the
sibling writers in this file (renameSession, mergeSessions). The
sessionModel test suite gains two cases that spy on
`store.sessions.updateSessionMetadata` to force `error` and
`version-mismatch` shapes and asserts the helper throws. The existing
route test at `hub/src/web/routes/sessions.test.ts:1015` already
covers the route-level 500 propagation for any error thrown out of
`archiveSession`, so no new route test is needed.
Imports `spyOn` from `bun:test` to match this test file's runtime
(the rest of the hub package uses bun:test, not vitest).
Refs tiann/hapi#916.
Co-authored-by: Cursor <cursoragent@cursor.com>
* revert(cli): drop HAPI_DEFAULT_ARCHIVE_REASON env override
Reverts `1c8972a3`. Bot review round 3 surfaced that the env-on-spawn
approach (the bot's own round-1 suggestion shape) mislabels
hub-restart-cascade SIGTERMs against runner-spawned children: systemd
killcgroup on `hapi-runner.service` stop sends SIGTERM to all
runner-children directly, and those would archive as 'Stopped by runner'
instead of 'Hub restart'.
The two suggestions are mutually incompatible without adding an IPC
channel (stdio: 'ipc' on spawn) so the runner can stamp
setArchiveReason via childProcess.send() before SIGTERMing. That is a
refactor, not a smallest-defensible change.
Going back to the simple shape: SIGTERM default is 'Hub restart' for
everyone, runner-internal stop paths share that label. The
audit-trail-correctness criterion from the #914 issue is met
(SIGTERM no longer falsely labels as 'User terminated'). Finer
attribution between cascade vs runner-stop is deferred as a follow-up.
Refs tiann/hapi#914.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): clean completions get 'Session completed', not 'Hub restart'
Addresses bot review round 4 of #923 (Major): every agent runner
(runClaude, runCodex, runCursor, runGemini, runKimi, runOpencode)
calls setSessionEndReason('completed') on the natural exit path
without touching archiveReason. With the SIGTERM default flipped to
'Hub restart', clean completions were now archived as restart
cascades.
Fix: setSessionEndReason flips archiveReason to 'Session completed'
when it transitions to 'completed' AND no caller has already overridden
the archive reason. This covers all six agent runners with a single
setter change (no per-runner edits).
Two new tests cover the natural-completion default and the override
precedence (explicit setArchiveReason still wins).
Refs tiann/hapi#914.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): restore inactive-session guard on /archive except split-brain
Addresses post-rebase bot review Major on #923: dropping requireActive
entirely let normal inactive non-archived rows (completed stubs, UI
Delete/Reopen targets) fall through to archiveSession, which could stamp
archivedBy=hub on sessions that were never active.
Restore the 409 for inactive rows unless metadata.lifecycleState is
still 'running' (hub-restart split-brain cleanup case from #916).
Two route tests cover the guard and the exception.
Refs tiann/hapi#916.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): merge runnerLifecycle tests after upstream rebase
Post-rebase fix: Session completed tests referenced makeFakeSession
which was renamed to createMockApiSessionWithMetadataCapture when
merging upstream hasExplicitSessionEndReason tests with #914 archive
reason coverage.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): pass lifecycle object to KillSession handler in Pi runner
Upstream #862 (Pi agent) landed after this branch was cut. runPi.ts
still registered the legacy bare cleanupAndExit callback, so web
Archive for Pi sessions would persist archiveReason: Hub restart
instead of User terminated. One-line fix matching the other six
agent runners.
Refs tiann/hapi#914.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: reproduce issue #898 (Codex fast mode service tier)
* fix(codex): add Fast mode (service tier) toggle and /fast command (closes#898)
* feat(codex+web): Fast mode UI toggle with full persistence
Wires the Codex Fast mode (service tier) end-to-end so it can be toggled
from the web composer and survives reload/handoff:
- shared: serviceTier on Session/SessionPatch, session-alive payload,
resume target, and a SessionServiceTierRequest schema
- cli: AgentSessionBase carries serviceTier through keepAlive; runCodex
syncs it to the session instance
- hub: service_tier column (schema v10 + migration), store setter,
sessionCache + syncEngine plumbing, POST /sessions/:id/service-tier
- web: api.setServiceTier + mutation, a Fast/Standard toggle in the
composer settings (gated to Codex GPT-5.5/5.4), and StatusBar now
reflects the real tier instead of the effort heuristic
Refs #898
* fix(codex): preserve unset/persisted service tier on startup keepalive
Addresses HAPI Bot [Major] on PR #904: applyCurrentConfigToSession ran
setServiceTier(currentServiceTier ?? null) on wrapper-ready, collapsing the
untouched `undefined` state into explicit Standard. The immediate
setCollaborationMode keepalive then persisted serviceTier: null, silently
downgrading resumed Fast sessions and disabling account-default Fast.
- Seed currentServiceTier from the persisted session (sessionInfo.serviceTier),
so a resumed Fast thread keeps running Fast.
- Only call setServiceTier when the tier is explicit (!== undefined), preserving
the three-state omit semantics at the keepalive boundary.
- Add regression tests: persisted Fast is re-asserted; untouched omits the tier.
* feat(codex+web): gate Fast toggle on catalog-advertised service tier
The Fast toggle was gated on a model-name regex (gpt-5.5/5.4), which still
showed a no-op control to API-key users — Fast credits only apply with
ChatGPT login. Codex's model/list catalog advertises the service tiers
actually available for each model in the current auth/plan context, so gate
on that instead:
- cli: capture serviceTiers (ids) per model in ModelListItem + normalizeModel
- shared: CodexModelSummary.serviceTiers (flows through the existing
getSessionCodexModels pass-through; no hub change needed)
- web: codexModelAdvertisesFastTier(sessionModel, models) replaces the regex;
SessionChat gates the toggle on it (hidden while the catalog is
loading/errored). The toggle now only appears when toggling it will
actually take effect.
Refs #898
* fix(codex): make explicit Standard service tier sticky across resume
Addresses HAPI Bot [Major] (round 2): a single persisted null conflated
"untouched" with "explicit Standard". A user who turned Fast off persisted
null, but startup mapped null -> undefined (untouched) and omitted serviceTier,
so an account/thread-default Fast could silently return after restart/resume.
Introduce a distinct stored representation:
- 'fast' / 'standard' are explicit user choices; null/undefined = untouched.
- Translate 'standard' -> Codex app-server serviceTier: null ONLY when building
thread/turn params (toAppServerServiceTier); untouched omits the field.
- /fast off now stores 'standard'; the web Standard option sends 'standard'.
- Tighten SessionServiceTierRequest to enum(['fast','standard']) so stray tier
strings are never forwarded.
Tests: sticky-Standard-on-resume regression; turn/thread params translate
'standard'->null and omit on untouched; hub route applies fast/standard and
rejects unsupported values + local sessions.
Refs #898
* fix(codex): recognize real Fast tier (id 'priority', name 'Fast') in catalog gate
Live E2E against an authed Codex session revealed the model catalog advertises
the Fast tier with id 'priority' and display name 'Fast' (not id 'fast'), so the
/fast/i gate — which only saw tier ids — wrongly hid the toggle for valid
ChatGPT users on gpt-5.5/gpt-5.4. Capture both the tier id and name as
lowercased tokens so the existing name-based match recognizes 'Fast'. The sent
value stays 'fast' (the documented service_tier value / raw additionalSpeedTiers
request tier). Verified end-to-end: gpt-5.5/gpt-5.4 gate on, gpt-5.4-mini off.
Refs #898
* fix(codex): preserve service tier across session resume
Resuming a Codex session spawns a fresh session (serviceTier null) and merges
the old one in. Unlike model/effort/permissionMode, serviceTier was neither
threaded through the resume spawn nor preserved in mergeSessionData, so a
resumed Fast (or explicit Standard) session silently reverted to the account
default.
Thread serviceTier through the spawn path like its siblings:
- hub: resumeSession passes session.serviceTier to spawnSession; rpcGateway +
syncEngine carry it in the spawn RPC payload; mergeSessionData preserves it
old->new (safety net).
- cli: SpawnSessionOptions.serviceTier; apiMachine forwards it; buildCliArgs
emits --service-tier for codex; the codex command parses it; runCodex seeds
currentServiceTier from the spawn override first (opts.serviceTier ??
sessionInfo.serviceTier), so a resumed thread immediately runs the right tier.
Verified end-to-end: set Fast -> kill process -> reopen -> resumed session (new
id) still runs Fast. Tests: buildCliArgs --service-tier (codex only), runCodex
spawn-override seed, mergeSessionData service-tier preservation.
Refs #898
* fix(codex): send advertised 'priority' tier id for Fast, not 'fast'
The model catalog advertises the Fast tier with request id 'priority' (display
name 'Fast'), and OpenAI docs confirm service_tier='fast' maps to the request
value 'priority'. The app-server serviceTier override is a raw request value
that does not validate unknown strings (a live probe accepted 'bogus-xyz'), so
sending 'fast' risks being silently ignored — no Fast applied.
Translate the stored 'fast' state to app-server 'priority' at the thread/turn
param boundary (toAppServerServiceTier); the stored/UI/command representation
stays 'fast'/'standard'. Verified live: a turn with serviceTier='priority' runs
and consumes the Fast-tier rate budget.
Addresses HAPI Bot [Major]. Refs #898
* fix(codex): validate --service-tier CLI value (fast|standard)
Addresses HAPI Bot [Minor]: the internal --service-tier spawn arg accepted any
non-empty string, unlike the web /service-tier enum, so a malformed value could
be seeded into currentServiceTier and persisted via keepalive. Parse it to
'fast'|'standard' and reject anything else, matching the web endpoint.
Refs #898
* fix(cli,hub): resolve typecheck errors in codex reasoning effort and notification test
- Cast `getModelReasoningEffort()` return (string | null) to
`ReasoningEffort | undefined` at three call sites in
codexLocalLauncher.ts and runCodex.ts where the narrower type is
expected.
- Add missing `modelReasoningEffort: null` default in
notificationHub.test.ts to satisfy the Session type contract.
These errors were introduced in 79a13d2 and have been failing CI on
main since 2026-04-10.
* fix(cli): add missing getModelReasoningEffort to test mock session
The test stub in codexLocalLauncher.test.ts was missing the
getModelReasoningEffort method added in 79a13d2, causing runtime
TypeError in CI.
Extract permission and model mode validation into reusable functions utilizing
@hapi/protocol schemas. Use isPermissionModeAllowedForFlavor and
isModelModeAllowedForFlavor to validate modes based on session flavor (claude/codex).
Replace inline type definitions with shared types from @hapi/protocol.
Consolidates duplicate cleanup, signal handling, and state management logic from
runClaude and runCodex into a reusable createRunnerLifecycle factory function.
Extracts mode switching handler and controlled user state updates into utilities.
Adds runLocalRemoteSession wrapper to handle session ready callbacks in loop base.
Implements bidirectional sync of permission/model modes between CLI sessions and web app. Adds Codex-specific permission modes (read-only, safe-yolo, yolo) alongside Claude's modes. Web can now control CLI session state via RPC set-session-config handler, while CLI broadcasts state changes through keep-alive payloads. UI controls are flavor-aware, showing appropriate modes for Claude vs Codex vs Gemini. Type centralization in api/types eliminates circular dependencies.
Implement comprehensive worktree session support allowing users to spawn sessions in temporary git worktrees. Includes backend worktree management, full-stack integration, and refined UI for session type selection.
Backend:
- Add worktree creation/removal utilities with branch management
- Track worktree metadata (basePath, branch, name, path) in session metadata
- Automatic cleanup of worktrees when sessions fail or exit
- Enhanced error handling with stderr tail logging
UI improvements:
- Redesign session type toggle with improved alignment and spacing
- Move worktree description inline with label for cleaner layout
- Add branch name input field that appears when worktree mode selected
- Auto-focus on worktree input when switching modes
- Reduce gap between radio options from gap-3 to gap-1.5
- Update descriptive text and placeholders for clarity
Integration:
- Thread worktree parameters through API client, RPC handlers, and daemon
- Add worktreeEnv utility to read worktree info from environment
- Update session spawning to support both simple and worktree modes
This refactoring introduces CLI argument overrides for sandbox and approval policy settings in remote mode, allowing users to specify security constraints via `--sandbox` and `--ask-for-approval` flags.
Changes:
- Add `CodexCliOverrides` type and `parseCodexCliOverrides()` utility to parse CLI flags like `--sandbox`, `-s`, `--ask-for-approval`, `-a`, along with convenience flags (`--full-auto`, `--dangerously-bypass-approvals-and-sandbox`)
- Extract complex start config building logic into `buildCodexStartConfig()` function with proper approval policy and sandbox resolution based on permission mode
- Thread `codexCliOverrides` through the session/loop/launcher chain and apply overrides only when permission mode is 'default'
- Update `codexRemoteLauncher` to use the new config builder and display appropriate warnings based on whether overrides are present
- Add comprehensive tests for both parsing and config building functions
Collect unknown CLI arguments from index.ts and pass them through the execution chain (runCodex → loop → CodexSession → codexLocalLauncher → codexLocal), similar to how claude already works. This enables users to pass CLI arguments like --model and --sandbox to the underlying codex process.
Filter out the 'resume' subcommand which is managed internally by hapi, while allowing other CLI arguments to pass through. Add warning log in remote mode when CLI args are ignored since remote mode uses message-based configuration instead. Include unit tests for the resume filtering logic.
Extracts duplicated exit/switch confirmation handling from CodexDisplay and RemoteModeDisplay into a custom useSwitchControls hook. Centralizes terminal state restoration (raw mode, keyboard protocol cleanup) into a restoreTerminalState utility function used across codex modules. Improves code reusability and maintainability.
Reorganized runCodex.ts to improve maintainability by extracting:
- CodexSession class for session lifecycle management
- CodexLocalLauncher and CodexRemoteLauncher for mode-specific initialization
- CodexEventConverter for MCP message handling and UI buffer updates
- CodexSessionScanner for resume file discovery
- emitReadyIfIdle utility for ready event emission
Added codexSessionId field to metadata schema for session tracking.
Updated UI components to work with refactored architecture.
- Add support for running CLI from TypeScript source when using tsx or similar tools
- Fix outdated happy__change_title reference to hapi__change_title in codex prompt
This commit rebrands the project from "Happy" to "HAPI" throughout the codebase, including documentation, comments, logs, and tool references. It also adds comprehensive README files for the server and web components, clarifies the monorepo structure in AGENTS.md and root README.md, and removes the outdated roadmap.md file.
Changes include:
- Rebrand references from Happy to HAPI in CLI, server, and web components
- MCP tool names updated from mcp__happy__ to mcp__hapi__
- Process/service names updated consistently
- New server/README.md with deployment and configuration guide
- New web/README.md with stack and development instructions
- Updated root README.md with quickstart guide
- Updated AGENTS.md with cleaner structure documentation
- Removed cli/roadmap.md (now superseded by documentation)
Enables building hapi as standalone Bun-compiled executables for macOS,
Linux, and Windows (x64/arm64). Adds build script, bootstrap entry point,
runtime asset management, and automatic deployment of bundled tools
(ripgrep, difftastic). Includes MCP stdio bridge support and proper
environment handling for compiled binaries. Updates documentation with
build and installation instructions for single executable distribution.
Introduce runtime path abstraction for Bun-compiled binaries that extracts
assets to ~/.happy/runtime/{version} instead of using project paths. This
enables proper distribution of CLI as a self-contained binary with embedded
tools (ripgrep, difftastic) and scripts that are extracted at runtime.