Skip to content

Commit ba6feaf

Browse files
yoyowormsHAPI
andcommitted
merge: upstream/main v0.20.2 — file download, auto perm mode, PWA update, drag-drop
Merge tiann/hapi upstream (33 commits, v0.20.2) into the liuxin fork. Notable adoptions: - file viewer download button (tiann#926) — the download UI the user asked about; decodes existing base64 content to a Blob, no new endpoint - Claude Code 'auto' permission mode (tiann#879) - in-app PWA update prompt when a new service worker is ready (tiann#946) — PwaUpdateBannerWithStatusOffset, adopted WITHOUT enabling voice - drag-and-drop files onto chat to attach (tiann#936) + Web Share Target (tiann#933) - Codex fast mode / service-tier toggle (tiann#904), session service_tier column + merge-preserve - four hub-restart-cascade cleanup bugs (tiann#923), stale-PID detection after abnormal runner shutdown (tiann#931), OutgoingMessageQueue flush before next turn (tiann#909), inactive-session send error surface (tiann#922) - OLED black theme + per-appearance custom colors (tiann#937), session-list attention tooltips (tiann#941), active-only filter + paginated Show-N-more (tiann#903), file-explorer state persistence (tiann#911) Conflict resolutions (22 files) — fork features preserved: - store/index.ts: both forks bumped schema to V10 with different migrateFromV9ToV10; combined into one migration doing BOTH our content_uuid dedup index (messages) AND upstream service_tier (sessions) - socket/server.ts: collapsed duplicate maxHttpBufferSize key into Math.max(SOCKET_MAX_HTTP_BUFFER_SIZE, 100MB) — keeps our 100MB upload floor while honoring upstream's image-buffer constant - spawnSession plumbing (apiMachine/rpcGateway/syncEngine): unioned our sandbox+continueLatest with upstream's serviceTier across signature, RPC payload, and the resume call site - App.tsx: kept signOut + voice-disabled; adopted PWA update banner (PwaUpdateProvider auto-merged into App() wrapper) - SessionChat: nested both wrappers — HappyChatProvider > ShareSeedConsumer + DragDropZone > thread - sessionModel.test: split the two same-named merge tests (our codex dedup + upstream service-tier) back into separate it() blocks - useSendMessage: kept fork queue-stop AND upstream onError surface; keyed onError by non-null sid - startHappyServer: kept fork's manual-rename skip in change_title - schemas/locales/StatusBar/HappyComposer: unioned both sides Post-merge type fixes: pinned-session test mock gained pendingRequests + nextScheduledAt (new required SessionSummary fields); legacy-CLI model backfill in applySessionConfig now extracts modelId from upstream's Pi {provider,modelId} union. Validation: typecheck green (cli/web/hub); full suite 2260 tests pass incl. the runner stress test. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
2 parents 170c554 + 2ab3b39 commit ba6feaf

231 files changed

Lines changed: 16135 additions & 2761 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,6 @@ cli/npm/main/
4747
test-results/
4848
playwright-report/
4949
e2e-output/
50+
.xyz-harness
51+
.agents/
52+
.pi/

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pr
145145
- **RPC**: CLI registers handlers (`rpc-register`), hub routes requests via `rpcGateway.ts`
146146
- **Versioned updates**: CLI sends `update-metadata`/`update-state` with version; hub rejects stale
147147
- **Session modes**: `local` (terminal) vs `remote` (web-controlled); switchable mid-session
148-
- **Permission modes**: `default`, `acceptEdits`, `bypassPermissions`, `plan`
148+
- **Permission modes**: `default`, `acceptEdits`, `auto`, `bypassPermissions`, `plan`
149149
- **Namespaces**: Multi-user isolation via `CLI_API_TOKEN:<namespace>` suffix
150150

151151
## Adding new web features — consider an FUE

bun.lock

Lines changed: 1988 additions & 1977 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/package.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@twsxtd/hapi",
3-
"version": "0.20.1",
3+
"version": "0.20.2",
44
"description": "App for agentic coding - access coding agent anywhere",
55
"author": "Kirill Dubovitskiy & weishu",
66
"license": "AGPL-3.0-only",
@@ -26,11 +26,11 @@
2626
}
2727
},
2828
"optionalDependencies": {
29-
"@twsxtd/hapi-darwin-arm64": "0.20.1",
30-
"@twsxtd/hapi-darwin-x64": "0.20.1",
31-
"@twsxtd/hapi-linux-arm64": "0.20.1",
32-
"@twsxtd/hapi-linux-x64": "0.20.1",
33-
"@twsxtd/hapi-win32-x64": "0.20.1"
29+
"@twsxtd/hapi-darwin-arm64": "0.20.2",
30+
"@twsxtd/hapi-darwin-x64": "0.20.2",
31+
"@twsxtd/hapi-linux-arm64": "0.20.2",
32+
"@twsxtd/hapi-linux-x64": "0.20.2",
33+
"@twsxtd/hapi-win32-x64": "0.20.2"
3434
},
3535
"scripts": {
3636
"postinstall": "node -e \"try{require('fs').chmodSync(require('path').join(__dirname,'bin','hapi.cjs'),0o755)}catch(e){}\"",
@@ -83,4 +83,4 @@
8383
"@types/parse-path": "7.0.3"
8484
},
8585
"packageManager": "bun@1.3.14"
86-
}
86+
}

cli/src/agent/backends/acp/AcpStdioTransport.test.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,25 @@ const guard = vi.hoisted(() => ({
55
unregister: vi.fn()
66
}));
77

8+
const spawnState = vi.hoisted(() => ({
9+
exitHandlers: [] as Array<(code: number | null, signal: NodeJS.Signals | null) => void>,
10+
stdinWrite: vi.fn<(chunk: string) => boolean>(() => true),
11+
exitCode: null as number | null
12+
}));
13+
814
vi.mock('./agentCliGuard', () => ({
915
registerActiveAcpTransport: guard.register,
1016
unregisterActiveAcpTransport: guard.unregister
1117
}));
1218

1319
vi.mock('node:child_process', () => ({
1420
spawn: vi.fn(() => {
21+
spawnState.exitHandlers = [];
1522
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
1623
const proc = {
24+
get exitCode() {
25+
return spawnState.exitCode;
26+
},
1727
stdout: {
1828
setEncoding: vi.fn(),
1929
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
@@ -26,12 +36,15 @@ vi.mock('node:child_process', () => ({
2636
handlers.set(`stderr:${event}`, [...(handlers.get(`stderr:${event}`) ?? []), handler]);
2737
})
2838
},
29-
stdin: { end: vi.fn(), write: vi.fn() },
39+
stdin: {
40+
end: vi.fn(),
41+
write: (chunk: string) => spawnState.stdinWrite(chunk)
42+
},
3043
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
31-
handlers.set(`proc:${event}`, [...(handlers.get(`proc:${event}`) ?? []), handler]);
3244
if (event === 'exit') {
33-
queueMicrotask(() => handler(0, null));
45+
spawnState.exitHandlers.push(handler as (code: number | null, signal: NodeJS.Signals | null) => void);
3446
}
47+
handlers.set(`proc:${event}`, [...(handlers.get(`proc:${event}`) ?? []), handler]);
3548
}),
3649
kill: vi.fn()
3750
};
@@ -45,6 +58,10 @@ describe('AcpStdioTransport agent CLI guard', () => {
4558
afterEach(() => {
4659
guard.register.mockClear();
4760
guard.unregister.mockClear();
61+
spawnState.stdinWrite.mockReset();
62+
spawnState.stdinWrite.mockReturnValue(true);
63+
spawnState.exitCode = null;
64+
spawnState.exitHandlers = [];
4865
});
4966

5067
test('registers cross-process guard only for Cursor agent command', async () => {
@@ -64,3 +81,39 @@ describe('AcpStdioTransport agent CLI guard', () => {
6481
}
6582
});
6683
});
84+
85+
describe('AcpStdioTransport closed stdin writes', () => {
86+
afterEach(() => {
87+
spawnState.stdinWrite.mockReset();
88+
spawnState.stdinWrite.mockReturnValue(true);
89+
spawnState.exitCode = null;
90+
spawnState.exitHandlers = [];
91+
});
92+
93+
test('rejects new requests after the ACP process exits instead of throwing from stdin.write', async () => {
94+
const transport = new AcpStdioTransport({ command: 'gemini' });
95+
spawnState.exitCode = 1;
96+
spawnState.stdinWrite.mockImplementation(() => {
97+
throw new Error('WritableIterable is closed');
98+
});
99+
100+
for (const handler of spawnState.exitHandlers) {
101+
handler(1, null);
102+
}
103+
104+
await expect(transport.sendRequest('session/new')).rejects.toThrow(
105+
'ACP process exited (code=1, signal=null)'
106+
);
107+
expect(() => transport.sendNotification('session/cancel', {})).not.toThrow();
108+
});
109+
110+
test('rejects pending requests when stdin.write throws', async () => {
111+
spawnState.stdinWrite.mockImplementation(() => {
112+
throw new Error('WritableIterable is closed');
113+
});
114+
115+
const transport = new AcpStdioTransport({ command: 'gemini' });
116+
await expect(transport.sendRequest('initialize')).rejects.toThrow('WritableIterable is closed');
117+
await expect(transport.sendRequest('session/new')).rejects.toThrow('WritableIterable is closed');
118+
});
119+
});

cli/src/agent/backends/acp/AcpStdioTransport.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ export class AcpStdioTransport {
6363
private nextId = 1;
6464
private protocolError: Error | null = null;
6565
private guardReleased = false;
66+
private closed = false;
67+
private closeError: Error | null = null;
6668

6769
constructor(options: {
6870
command: string;
@@ -94,14 +96,14 @@ export class AcpStdioTransport {
9496
this.releaseAgentCliGuard();
9597
const message = `ACP process exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`;
9698
logger.debug(message);
97-
this.rejectAllPending(new Error(message));
99+
this.markClosed(new Error(message));
98100
});
99101

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

126128
async sendRequest(method: string, params?: unknown, options?: { timeoutMs?: number }): Promise<unknown> {
129+
if (this.closed) {
130+
return Promise.reject(this.closeError ?? new Error('ACP transport is closed'));
131+
}
132+
127133
const id = this.nextId++;
128134
const payload: JsonRpcRequest = {
129135
jsonrpc: '2.0',
@@ -167,6 +173,10 @@ export class AcpStdioTransport {
167173
}
168174

169175
sendNotification(method: string, params?: unknown): void {
176+
if (this.closed) {
177+
return;
178+
}
179+
170180
const payload: JsonRpcNotification = {
171181
jsonrpc: '2.0',
172182
method,
@@ -179,7 +189,7 @@ export class AcpStdioTransport {
179189
this.process.stdin.end();
180190
await killProcessByChildProcess(this.process);
181191
this.releaseAgentCliGuard();
182-
this.rejectAllPending(new Error('ACP transport closed'));
192+
this.markClosed(new Error('ACP transport closed'));
183193
}
184194

185195
private releaseAgentCliGuard(): void {
@@ -302,8 +312,27 @@ export class AcpStdioTransport {
302312
}
303313

304314
private writePayload(payload: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse): void {
305-
const serialized = JSON.stringify(payload);
306-
this.process.stdin.write(`${serialized}\n`);
315+
if (this.closed) {
316+
return;
317+
}
318+
319+
try {
320+
const serialized = JSON.stringify(payload);
321+
this.process.stdin.write(`${serialized}\n`);
322+
} catch (error) {
323+
const writeError = error instanceof Error ? error : new Error(String(error));
324+
this.markClosed(writeError);
325+
}
326+
}
327+
328+
private markClosed(error: Error): void {
329+
if (this.closed) {
330+
return;
331+
}
332+
333+
this.closed = true;
334+
this.closeError = error;
335+
this.rejectAllPending(error);
307336
}
308337

309338
private rejectAllPending(error: Error): void {

cli/src/agent/localHandoff.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ describe('registerLocalHandoffHandler', () => {
1212
const lifecycle = {
1313
setArchiveReason: vi.fn(),
1414
setSessionEndReason: vi.fn(),
15+
hasExplicitSessionEndReason: vi.fn(() => false),
1516
cleanupAndExit: vi.fn(async () => {})
1617
}
1718

cli/src/agent/messageConverter.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@ describe('convertAgentMessage', () => {
5050
});
5151
});
5252

53+
it('converts agent errors into error wire payloads', () => {
54+
const converted = convertAgentMessage({
55+
type: 'error',
56+
message: 'Cursor Agent failed: authentication required'
57+
});
58+
59+
expect(converted).toEqual({
60+
type: 'error',
61+
message: 'Cursor Agent failed: authentication required'
62+
});
63+
});
64+
5365
it('converts usage messages into token_count payloads', () => {
5466
const converted = convertAgentMessage({
5567
type: 'usage',

0 commit comments

Comments
 (0)