Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e45fde5
fix(claude): stop 1M/200k context-window flicker in the status bar (#…
junmo-kim Jul 11, 2026
43e7b6b
fix(cli): compute macOS machine-health memory from vm_stat (#990)
junmo-kim Jul 11, 2026
afdcd92
fix: normalize Windows drive roots (#979)
quecai-niu Jul 11, 2026
a2465c7
[codex] fix Qwen realtime compatibility (#977)
quecai-niu Jul 11, 2026
58cfc33
fix(web): show standard for untouched Codex service tier (#1010)
swear01 Jul 11, 2026
018bcb8
fix(web): clean up React tests globally (#993)
swear01 Jul 11, 2026
a82dd49
feat(web): markdown Source | Preview toggle in session file pane (#957)
heavygee Jul 11, 2026
65e1708
feat(web): mermaid diagram lightbox on click (#741)
heavygee Jul 11, 2026
5a377e3
fix(codex): defer session persistence until user activity
tiann Jul 12, 2026
942a1df
Release version 0.21.0
tiann Jul 12, 2026
8782b8a
fix(codex): align local transcript message projection
tiann Jul 12, 2026
4c76668
refine message actions and metadata
tiann Jul 12, 2026
d160203
fix(codex): support dynamic reasoning efforts (#1012)
ejjcc Jul 12, 2026
d9ea9db
fix(codex): preserve native safety behavior (#1024)
swear01 Jul 12, 2026
d0c8391
fix(opencode): preserve ACP text deltas (#1023)
Hao76 Jul 12, 2026
b104c38
fix(web): show Codex reasoning effort in SessionHeader (#1016)
swear01 Jul 12, 2026
73584e9
feat(cursor): multitask slash, autoReview mode, native worktree/add-d…
swear01 Jul 12, 2026
474db94
test(cli): stabilize runner integration timeouts (#1011)
swear01 Jul 12, 2026
d97b270
fix(codex): bridge MCP elicitation through user input (#1008)
swear01 Jul 12, 2026
de07643
fix(codex): improve local session import compatibility (#995)
Himehane Jul 12, 2026
b9eed7c
feat: add Grok Build support (#1030)
swear01 Jul 13, 2026
f6ad345
feat(web): redesign responsive settings navigation (#1027)
swear01 Jul 13, 2026
8ee0450
fix(hub,cli): coerce null session activeAt so resume cannot 500 (#1026)
heavygee Jul 13, 2026
1c83460
Release version 0.22.3
tiann Jul 13, 2026
87f5c78
fix(codex): scan transcripts incrementally (#1031)
zj1123581321 Jul 13, 2026
a651e67
feat(web): searchable session picker on share target (#980)
heavygee Jun 29, 2026
cdaa996
fix(web): defer share pending consume until session is active
Jul 14, 2026
25b0089
fix(web): bind share pending transfer to target session id
Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# HAPI

Run official Claude Code / Codex / Gemini / OpenCode sessions locally and control them remotely through a Web / PWA / Telegram Mini App.
Run official Claude Code / Codex / Cursor Agent / Grok Build / OpenCode sessions locally and control them remotely through a Web / PWA / Telegram Mini App.

> **Why HAPI?** HAPI is a local-first alternative to Happy. See [Why Not Happy?](docs/guide/why-hapi.md) for the key differences.

Expand All @@ -9,7 +9,7 @@ Run official Claude Code / Codex / Gemini / OpenCode sessions locally and contro
- **Seamless Handoff** - Work locally, switch to remote when needed, switch back anytime. No context loss, no session restart.
- **Native First** - HAPI wraps your AI agent instead of replacing it. Same terminal, same experience, same muscle memory.
- **AFK Without Stopping** - Step away from your desk? Approve AI requests from your phone with one tap.
- **Your AI, Your Choice** - Claude Code, Codex, Cursor Agent, Gemini, OpenCode—different models, one unified workflow.
- **Your AI, Your Choice** - Claude Code, Codex, Cursor Agent, Grok Build, OpenCode—different agents, one unified workflow.
- **Terminal Anywhere** - Run commands from your phone or browser, directly connected to the working machine.
- **Voice Control** - Talk to your AI agent hands-free using the built-in voice assistant.
- **Workspace Browser** - Opt-in via one or more `hapi runner start --workspace-root <path>` flags: browse scoped file trees from the web and start sessions in allowed subdirectories.
Expand Down Expand Up @@ -38,6 +38,7 @@ For self-hosted options (Cloudflare Tunnel, Tailscale), see [Installation](docs/
- [App](docs/guide/pwa.md)
- [How it Works](docs/guide/how-it-works.md)
- [Cursor Agent](docs/guide/cursor.md)
- [Grok Build](docs/guide/grok.md)
- [Voice Assistant](docs/guide/voice-assistant.md)
- [Why HAPI](docs/guide/why-hapi.md)
- [FAQ](docs/guide/faq.md)
Expand Down
24 changes: 12 additions & 12 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# hapi CLI

Run Claude Code, Codex, Cursor Agent, Gemini, or OpenCode sessions from your terminal and control them remotely through the hapi hub.
Run Claude Code, Codex, Cursor Agent, Grok Build, or OpenCode sessions from your terminal and control them remotely through the hapi hub.

## What it does

- Starts Claude Code sessions and registers them with hapi-hub.
- Starts Codex mode for OpenAI-based sessions.
- Starts Cursor Agent mode for Cursor CLI sessions.
- Starts Grok Build locally or via ACP for remote sessions.
- Starts Gemini mode via ACP (Anthropic Code Plugins).
- Starts OpenCode mode via ACP and its plugin hook system.
- Provides an MCP stdio bridge for external tools.
Expand All @@ -30,6 +31,7 @@ Run Claude Code, Codex, Cursor Agent, Gemini, or OpenCode sessions from your ter
- `hapi cursor` - Start Cursor Agent mode. See `src/cursor/runCursor.ts`.
Supports `hapi cursor resume <chatId>`, `hapi cursor --continue`, `--mode plan|ask`, `--yolo`, `--model`.
Local and remote modes supported; remote uses `agent -p` with stream-json.
- `hapi grok` - Start Grok Build mode. See `src/grok/runGrok.ts`.
- `hapi gemini` - Start Gemini mode via ACP. See `src/agent/runners/runAgentSession.ts`.
Note: Gemini runs in remote mode only; it waits for messages from the hub UI/Telegram.
- `hapi opencode` - Start OpenCode mode via ACP. See `src/opencode/runOpencode.ts`.
Expand Down Expand Up @@ -127,6 +129,7 @@ Data is stored in `~/.hapi/` (or `$HAPI_HOME`):

- Claude CLI installed and logged in (`claude` on PATH).
- Cursor Agent CLI installed (`agent` on PATH) for `hapi cursor`. Install: `curl https://cursor.com/install -fsS | bash` (macOS/Linux), `irm 'https://cursor.com/install?win32=true' | iex` (Windows).
- Grok Build CLI installed (`grok` on PATH) for `hapi grok`. Authenticate with `grok login --device-auth` on headless runner machines, or set `XAI_API_KEY`.
- OpenCode CLI installed (`opencode` on PATH).
- Bun for building from source.

Expand All @@ -152,6 +155,7 @@ bun run build:single-exe
- `src/claude/` - Claude Code integration.
- `src/codex/` - Codex mode integration.
- `src/cursor/` - Cursor Agent integration.
- `src/grok/` - Grok Build native TUI + ACP integration.
- `src/agent/` - Multi-agent support (Gemini via ACP).
- `src/opencode/` - OpenCode ACP + hook integration.
- `src/runner/` - Background service.
Expand Down
14 changes: 7 additions & 7 deletions cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@twsxtd/hapi",
"version": "0.20.2",
"version": "0.22.3",
"description": "App for agentic coding - access coding agent anywhere",
"author": "Kirill Dubovitskiy & weishu",
"license": "AGPL-3.0-only",
Expand All @@ -26,11 +26,11 @@
}
},
"optionalDependencies": {
"@twsxtd/hapi-darwin-arm64": "0.20.2",
"@twsxtd/hapi-darwin-x64": "0.20.2",
"@twsxtd/hapi-linux-arm64": "0.20.2",
"@twsxtd/hapi-linux-x64": "0.20.2",
"@twsxtd/hapi-win32-x64": "0.20.2"
"@twsxtd/hapi-darwin-arm64": "0.22.3",
"@twsxtd/hapi-darwin-x64": "0.22.3",
"@twsxtd/hapi-linux-arm64": "0.22.3",
"@twsxtd/hapi-linux-x64": "0.22.3",
"@twsxtd/hapi-win32-x64": "0.22.3"
},
"scripts": {
"postinstall": "node -e \"try{require('fs').chmodSync(require('path').join(__dirname,'bin','hapi.cjs'),0o755)}catch(e){}\"",
Expand Down Expand Up @@ -83,4 +83,4 @@
"@types/parse-path": "7.0.3"
},
"packageManager": "bun@1.3.14"
}
}
19 changes: 19 additions & 0 deletions cli/src/agent/backends/acp/AcpMessageHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,25 @@ describe('AcpMessageHandler', () => {
expect(messages).toEqual([{ type: 'text', text: 'hello world' }]);
});

it('preserves overlapping text chunks in delta mode', () => {
const messages: AgentMessage[] = [];
const handler = new AcpMessageHandler(
(message) => messages.push(message),
{ textChunkMode: 'delta' }
);

for (const text of ['|-----|', '-----|', '-----|\n']) {
handler.handleUpdate({
sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk,
content: { type: 'text', text }
});
}

handler.flushText();

expect(messages).toEqual([{ type: 'text', text: '|-----|-----|-----|\n' }]);
});

it('keeps existing tool name when update only has kind fallback', () => {
const messages: AgentMessage[] = [];
const handler = new AcpMessageHandler((message) => messages.push(message));
Expand Down
17 changes: 16 additions & 1 deletion cli/src/agent/backends/acp/AcpMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,8 @@ function normalizePlanEntries(entries: unknown): PlanItem[] {
return items;
}

export type AcpTextChunkMode = 'dedupe' | 'delta';

function getSuffixPrefixOverlap(base: string, next: string): number {
const maxOverlap = Math.min(base.length, next.length);
for (let length = maxOverlap; length > 0; length -= 1) {
Expand All @@ -380,8 +382,14 @@ export class AcpMessageHandler {
private lastReasoningSnapshotAt: number | null = null;
private lastReasoningSnapshotText = '';
private reasoningSnapshotEmitted = false;
private readonly textChunkMode: AcpTextChunkMode;

constructor(private readonly onMessage: (message: AgentMessage) => void) {}
constructor(
private readonly onMessage: (message: AgentMessage) => void,
options: { textChunkMode?: AcpTextChunkMode } = {}
) {
this.textChunkMode = options.textChunkMode ?? 'dedupe';
}

/**
* Emits any buffered assistant text as a single message and clears the
Expand Down Expand Up @@ -444,6 +452,13 @@ export class AcpMessageHandler {
}

private appendTextChunk(text: string): void {
if (this.textChunkMode === 'delta') {
if (text) {
this.bufferedText += text;
}
return;
}

if (!text) {
return;
}
Expand Down
119 changes: 119 additions & 0 deletions cli/src/agent/backends/acp/AcpSdkBackend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,79 @@ describe('AcpSdkBackend', () => {
});
});

it('captures Grok reasoning efforts from x.ai session metadata and switches with set_mode', async () => {
const backend = new AcpSdkBackend({ command: 'grok' });
const calls: Array<{ method: string; params: unknown }> = [];
const backendInternal = backend as unknown as {
transport: { sendRequest: (method: string, params: unknown) => Promise<unknown>; close: () => Promise<void> } | null;
};
backendInternal.transport = {
sendRequest: async (method, params) => {
calls.push({ method, params });
if (method === 'session/new') {
return {
sessionId: 'grok-session-1',
models: {
currentModelId: 'grok-4.5',
availableModels: [{
modelId: 'grok-4.5',
name: 'Grok 4.5',
_meta: {
reasoningEfforts: [
{ value: 'high', label: 'High Effort', default: true },
{ value: 'low', label: 'Low Effort', default: false }
]
}
}]
},
_meta: {
availableCommands: [{ name: 'auto' }],
'x.ai/sessionConfig': {
options: [
{ id: 'high', category: 'mode', label: 'High Effort', selected: false },
{ id: 'low', category: 'mode', label: 'Low Effort', selected: true }
]
}
}
};
}
if (method === 'session/set_mode') return { meta: null };
return null;
},
close: async () => {}
};

const sessionId = await backend.newSession({ cwd: '/tmp/x', mcpServers: [] });

expect(backend.getSessionModelsMetadata(sessionId)).toEqual({
availableModels: [{
modelId: 'grok-4.5',
name: 'Grok 4.5',
reasoningEfforts: [
{ value: 'high', name: 'High Effort', isDefault: true },
{ value: 'low', name: 'Low Effort', isDefault: false }
]
}],
currentModelId: 'grok-4.5'
});
expect(backend.getThoughtLevelConfigOption(sessionId)).toMatchObject({
currentValue: 'low',
options: [
{ value: 'high', name: 'High Effort' },
{ value: 'low', name: 'Low Effort' }
]
});
expect(backend.hasAvailableCommand(sessionId, 'auto')).toBe(true);

await backend.setMode(sessionId, 'high');

expect(calls).toContainEqual({
method: 'session/set_mode',
params: { sessionId, modeId: 'high' }
});
expect(backend.getThoughtLevelConfigOption(sessionId)?.currentValue).toBe('high');
});

it('merges configOptions model variants into availableModels when both are present', async () => {
const backend = new AcpSdkBackend({ command: 'agent' });
const backendInternal = backend as unknown as {
Expand Down Expand Up @@ -755,6 +828,52 @@ describe('AcpSdkBackend', () => {
expect(realtimeUsage.map((m) => m.contextTokens)).toEqual([1_000, 2_500]);
});

it('forwards title changes from session_info_update', () => {
const backend = new AcpSdkBackend({ command: 'agent' });
const updates: Array<{ title?: string | null }> = [];
backend.setSessionInfoUpdateListener((update) => updates.push(update));

const backendInternal = backend as unknown as {
activeSessionId: string | null;
handleSessionUpdate: (params: unknown) => void;
};
backendInternal.activeSessionId = 'session-1';

backendInternal.handleSessionUpdate({
sessionId: 'session-1',
update: {
sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate,
title: 'Native ACP title'
}
});
backendInternal.handleSessionUpdate({
sessionId: 'session-1',
update: {
sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate,
title: null
}
});
backendInternal.handleSessionUpdate({
sessionId: 'session-1',
update: {
sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate,
title: 123
}
});
backendInternal.handleSessionUpdate({
sessionId: 'other-session',
update: {
sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate,
title: 'Wrong session'
}
});

expect(updates).toEqual([
{ title: 'Native ACP title' },
{ title: null }
]);
});

it('emits a context-only usage on finalize when the prompt response carries no usage', async () => {
backendStatics.UPDATE_QUIET_PERIOD_MS = 25;
backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200;
Expand Down
Loading
Loading