Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c87f88f
fix(web): disable single-dollar inline math in remark-math (#805)
heavygee Jun 8, 2026
9349acb
fix(web): use session flavor label in voice context formatters (close…
heavygee Jun 8, 2026
edc3acc
fix(cursor): requeue user message on transient agent exit (auth, rate…
heavygee Jun 8, 2026
cd99cfb
fix(hub): preserve session metadata across archive transitions (#825)
heavygee Jun 8, 2026
cb72703
feat(hub+web): add POST /sessions/:id/reopen + Reopen button on inact…
heavygee Jun 8, 2026
6d2d0d4
fix(cursor): trim #784 safety patch to marker-only on legacy stream-j…
heavygee Jun 8, 2026
ad038bb
fix(cursor): merge SKU catalog under ACP lock and refcount agent guar…
swear01 Jun 8, 2026
8094b50
fix(web): hide sidebar fake sessions for Cursor resume/archive (#836)
swear01 Jun 8, 2026
fa363c2
fix(cursor): register cursorSessionId before ACP session/load (#837)
swear01 Jun 8, 2026
deb05bb
Auto-approve Codex title MCP tool
tiann Jun 8, 2026
393cd7b
feat(web): scratchlist v1.1 — composer-toggle drawer + reusable FUE p…
heavygee Jun 8, 2026
1f92a31
Release version 0.20.1
tiann Jun 8, 2026
e2625b8
feat: add Fable model presets for Claude sessions (#860)
ejjcc Jun 10, 2026
3473a88
feat(web): auto-return to chat when remote terminal exits (#857)
heavygee Jun 10, 2026
cad58cf
fix(opencode): use ACP-reported reasoning effort options (#853)
swear01 Jun 10, 2026
ddf3a55
fix(web): add missing i18n keys for session.inactive banner (#851)
swear01 Jun 10, 2026
55d1bbb
feat(cursor): invisible sync-on-open migrator from legacy stream-json…
heavygee Jun 10, 2026
a617601
fix(runner): self-restart resilience under systemd / external process…
heavygee Jun 10, 2026
17439ae
fix(cli): bypass proxy for loopback addresses at CLI entrypoint (#868)
tiann Jun 10, 2026
e6b723f
feat(web): auto-focus terminal on open (#876)
heavygee Jun 11, 2026
434cd90
fix(cursor-acp): surface ACP stdin write failures to web UI (#870)
swear01 Jun 11, 2026
3e2e482
fix(cursor): migrator path-priority + ambiguity surface (closes #844 …
heavygee Jun 11, 2026
d464651
Release version 0.20.2
tiann Jun 11, 2026
93d0041
feat: support Claude Code 'auto' permission mode (closes #858) (#879)
tiann Jun 11, 2026
bf7c022
fix(web): highlight 'Default' in cursor flat-mode model picker (#46)
heavygee Jun 13, 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
39 changes: 38 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,46 @@ Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pr
- **RPC**: CLI registers handlers (`rpc-register`), hub routes requests via `rpcGateway.ts`
- **Versioned updates**: CLI sends `update-metadata`/`update-state` with version; hub rejects stale
- **Session modes**: `local` (terminal) vs `remote` (web-controlled); switchable mid-session
- **Permission modes**: `default`, `acceptEdits`, `bypassPermissions`, `plan`
- **Permission modes**: `default`, `acceptEdits`, `auto`, `bypassPermissions`, `plan`
- **Namespaces**: Multi-user isolation via `CLI_API_TOKEN:<namespace>` suffix

## Adding new web features — consider an FUE

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

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

Pattern (~10 lines around the affordance):

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

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

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

## Critical Thinking

1. Fix root cause (not band-aid).
Expand Down
24 changes: 14 additions & 10 deletions bun.lock

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

12 changes: 6 additions & 6 deletions cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@twsxtd/hapi",
"version": "0.20.0",
"version": "0.20.2",
"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.0",
"@twsxtd/hapi-darwin-x64": "0.20.0",
"@twsxtd/hapi-linux-arm64": "0.20.0",
"@twsxtd/hapi-linux-x64": "0.20.0",
"@twsxtd/hapi-win32-x64": "0.20.0"
"@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"
},
"scripts": {
"postinstall": "node -e \"try{require('fs').chmodSync(require('path').join(__dirname,'bin','hapi.cjs'),0o755)}catch(e){}\"",
Expand Down
59 changes: 56 additions & 3 deletions cli/src/agent/backends/acp/AcpStdioTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,25 @@ const guard = vi.hoisted(() => ({
unregister: vi.fn()
}));

const spawnState = vi.hoisted(() => ({
exitHandlers: [] as Array<(code: number | null, signal: NodeJS.Signals | null) => void>,
stdinWrite: vi.fn<(chunk: string) => boolean>(() => true),
exitCode: null as number | null
}));

vi.mock('./agentCliGuard', () => ({
registerActiveAcpTransport: guard.register,
unregisterActiveAcpTransport: guard.unregister
}));

vi.mock('node:child_process', () => ({
spawn: vi.fn(() => {
spawnState.exitHandlers = [];
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
const proc = {
get exitCode() {
return spawnState.exitCode;
},
stdout: {
setEncoding: vi.fn(),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
Expand All @@ -26,12 +36,15 @@ vi.mock('node:child_process', () => ({
handlers.set(`stderr:${event}`, [...(handlers.get(`stderr:${event}`) ?? []), handler]);
})
},
stdin: { end: vi.fn(), write: vi.fn() },
stdin: {
end: vi.fn(),
write: (chunk: string) => spawnState.stdinWrite(chunk)
},
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
handlers.set(`proc:${event}`, [...(handlers.get(`proc:${event}`) ?? []), handler]);
if (event === 'exit') {
queueMicrotask(() => handler(0, null));
spawnState.exitHandlers.push(handler as (code: number | null, signal: NodeJS.Signals | null) => void);
}
handlers.set(`proc:${event}`, [...(handlers.get(`proc:${event}`) ?? []), handler]);
}),
kill: vi.fn()
};
Expand All @@ -45,6 +58,10 @@ describe('AcpStdioTransport agent CLI guard', () => {
afterEach(() => {
guard.register.mockClear();
guard.unregister.mockClear();
spawnState.stdinWrite.mockReset();
spawnState.stdinWrite.mockReturnValue(true);
spawnState.exitCode = null;
spawnState.exitHandlers = [];
});

test('registers cross-process guard only for Cursor agent command', async () => {
Expand All @@ -64,3 +81,39 @@ describe('AcpStdioTransport agent CLI guard', () => {
}
});
});

describe('AcpStdioTransport closed stdin writes', () => {
afterEach(() => {
spawnState.stdinWrite.mockReset();
spawnState.stdinWrite.mockReturnValue(true);
spawnState.exitCode = null;
spawnState.exitHandlers = [];
});

test('rejects new requests after the ACP process exits instead of throwing from stdin.write', async () => {
const transport = new AcpStdioTransport({ command: 'gemini' });
spawnState.exitCode = 1;
spawnState.stdinWrite.mockImplementation(() => {
throw new Error('WritableIterable is closed');
});

for (const handler of spawnState.exitHandlers) {
handler(1, null);
}

await expect(transport.sendRequest('session/new')).rejects.toThrow(
'ACP process exited (code=1, signal=null)'
);
expect(() => transport.sendNotification('session/cancel', {})).not.toThrow();
});

test('rejects pending requests when stdin.write throws', async () => {
spawnState.stdinWrite.mockImplementation(() => {
throw new Error('WritableIterable is closed');
});

const transport = new AcpStdioTransport({ command: 'gemini' });
await expect(transport.sendRequest('initialize')).rejects.toThrow('WritableIterable is closed');
await expect(transport.sendRequest('session/new')).rejects.toThrow('WritableIterable is closed');
});
});
39 changes: 34 additions & 5 deletions cli/src/agent/backends/acp/AcpStdioTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export class AcpStdioTransport {
private nextId = 1;
private protocolError: Error | null = null;
private guardReleased = false;
private closed = false;
private closeError: Error | null = null;

constructor(options: {
command: string;
Expand Down Expand Up @@ -94,14 +96,14 @@ export class AcpStdioTransport {
this.releaseAgentCliGuard();
const message = `ACP process exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`;
logger.debug(message);
this.rejectAllPending(new Error(message));
this.markClosed(new Error(message));
});

this.process.on('error', (error) => {
this.releaseAgentCliGuard();
logger.debug('[ACP] Process error', error);
const message = error instanceof Error ? error.message : String(error);
this.rejectAllPending(new Error(
this.markClosed(new Error(
`Failed to spawn ${options.command}: ${message}. Is it installed and on PATH?`,
{ cause: error }
));
Expand All @@ -124,6 +126,10 @@ export class AcpStdioTransport {
static readonly DEFAULT_TIMEOUT_MS = 120_000;

async sendRequest(method: string, params?: unknown, options?: { timeoutMs?: number }): Promise<unknown> {
if (this.closed) {
return Promise.reject(this.closeError ?? new Error('ACP transport is closed'));
}

const id = this.nextId++;
const payload: JsonRpcRequest = {
jsonrpc: '2.0',
Expand Down Expand Up @@ -167,6 +173,10 @@ export class AcpStdioTransport {
}

sendNotification(method: string, params?: unknown): void {
if (this.closed) {
return;
}

const payload: JsonRpcNotification = {
jsonrpc: '2.0',
method,
Expand All @@ -179,7 +189,7 @@ export class AcpStdioTransport {
this.process.stdin.end();
await killProcessByChildProcess(this.process);
this.releaseAgentCliGuard();
this.rejectAllPending(new Error('ACP transport closed'));
this.markClosed(new Error('ACP transport closed'));
}

private releaseAgentCliGuard(): void {
Expand Down Expand Up @@ -302,8 +312,27 @@ export class AcpStdioTransport {
}

private writePayload(payload: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse): void {
const serialized = JSON.stringify(payload);
this.process.stdin.write(`${serialized}\n`);
if (this.closed) {
return;
}

try {
const serialized = JSON.stringify(payload);
this.process.stdin.write(`${serialized}\n`);
} catch (error) {
const writeError = error instanceof Error ? error : new Error(String(error));
this.markClosed(writeError);
}
}

private markClosed(error: Error): void {
if (this.closed) {
return;
}

this.closed = true;
this.closeError = error;
this.rejectAllPending(error);
}

private rejectAllPending(error: Error): void {
Expand Down
Loading
Loading