mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
refactor: extract local agent spawn helper and unify process tree cleanup (#410)
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { logger } from "@/ui/logger";
|
||||
import { restoreTerminalState } from "@/ui/terminalState";
|
||||
import { claudeCheckSession } from "./utils/claudeCheckSession";
|
||||
import { getProjectPath } from "./utils/path";
|
||||
import { appendMcpConfigArg } from "./utils/mcpConfig";
|
||||
import { systemPrompt } from "./utils/systemPrompt";
|
||||
import { withBunRuntimeEnv } from "@/utils/bunRuntime";
|
||||
import { spawnWithAbort } from "@/utils/spawnWithAbort";
|
||||
import { spawnWithTerminalGuard } from "@/utils/spawnWithTerminalGuard";
|
||||
import { getHapiBlobsDir } from "@/constants/uploadPaths";
|
||||
import { stripNewlinesForWindowsShellArg } from "@/utils/shellEscape";
|
||||
import { getDefaultClaudeCodePath } from "./sdk/utils";
|
||||
@@ -91,8 +90,7 @@ export async function claudeLocal(opts: {
|
||||
|
||||
// Spawn the process
|
||||
try {
|
||||
process.stdin.pause();
|
||||
await spawnWithAbort({
|
||||
await spawnWithTerminalGuard({
|
||||
command: claudeCommand,
|
||||
args,
|
||||
cwd: opts.path,
|
||||
@@ -107,8 +105,6 @@ export async function claudeLocal(opts: {
|
||||
});
|
||||
} finally {
|
||||
cleanupMcpConfig?.();
|
||||
process.stdin.resume();
|
||||
restoreTerminalState();
|
||||
}
|
||||
|
||||
return startFrom ?? null;
|
||||
|
||||
+14
-21
@@ -1,6 +1,5 @@
|
||||
import { logger } from '@/ui/logger';
|
||||
import { restoreTerminalState } from '@/ui/terminalState';
|
||||
import { spawnWithAbort } from '@/utils/spawnWithAbort';
|
||||
import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard';
|
||||
import { buildMcpServerConfigArgs, buildDeveloperInstructionsArg } from './utils/codexMcpConfig';
|
||||
import { codexSystemPrompt } from './utils/systemPrompt';
|
||||
|
||||
@@ -68,23 +67,17 @@ export async function codexLocal(opts: {
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdin.pause();
|
||||
try {
|
||||
await spawnWithAbort({
|
||||
command: 'codex',
|
||||
args,
|
||||
cwd: opts.path,
|
||||
env: process.env,
|
||||
signal: opts.abort,
|
||||
logLabel: 'CodexLocal',
|
||||
spawnName: 'codex',
|
||||
installHint: 'Codex CLI',
|
||||
includeCause: true,
|
||||
logExit: true,
|
||||
shell: process.platform === 'win32'
|
||||
});
|
||||
} finally {
|
||||
process.stdin.resume();
|
||||
restoreTerminalState();
|
||||
}
|
||||
await spawnWithTerminalGuard({
|
||||
command: 'codex',
|
||||
args,
|
||||
cwd: opts.path,
|
||||
env: process.env,
|
||||
signal: opts.abort,
|
||||
logLabel: 'CodexLocal',
|
||||
spawnName: 'codex',
|
||||
installHint: 'Codex CLI',
|
||||
includeCause: true,
|
||||
logExit: true,
|
||||
shell: process.platform === 'win32'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { logger } from '@/ui/logger';
|
||||
import { restoreTerminalState } from '@/ui/terminalState';
|
||||
import { spawnWithAbort } from '@/utils/spawnWithAbort';
|
||||
import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard';
|
||||
|
||||
/**
|
||||
* Filter out 'resume' subcommand which is managed internally by hapi.
|
||||
@@ -61,23 +60,17 @@ export async function cursorLocal(opts: {
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdin.pause();
|
||||
try {
|
||||
await spawnWithAbort({
|
||||
command: 'agent',
|
||||
args,
|
||||
cwd: opts.path,
|
||||
env: process.env,
|
||||
signal: opts.abort,
|
||||
logLabel: 'CursorLocal',
|
||||
spawnName: 'agent',
|
||||
installHint: 'Cursor Agent CLI (curl https://cursor.com/install -fsS | bash)',
|
||||
includeCause: true,
|
||||
logExit: true,
|
||||
shell: process.platform === 'win32'
|
||||
});
|
||||
} finally {
|
||||
process.stdin.resume();
|
||||
restoreTerminalState();
|
||||
}
|
||||
await spawnWithTerminalGuard({
|
||||
command: 'agent',
|
||||
args,
|
||||
cwd: opts.path,
|
||||
env: process.env,
|
||||
signal: opts.abort,
|
||||
logLabel: 'CursorLocal',
|
||||
spawnName: 'agent',
|
||||
installHint: 'Cursor Agent CLI (curl https://cursor.com/install -fsS | bash)',
|
||||
includeCause: true,
|
||||
logExit: true,
|
||||
shell: process.platform === 'win32'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { logger } from '@/ui/logger';
|
||||
import { killProcessByChildProcess } from '@/utils/process';
|
||||
import { convertAgentMessage } from '@/agent/messageConverter';
|
||||
import { OpencodeDisplay } from '@/ui/ink/OpencodeDisplay';
|
||||
import {
|
||||
@@ -177,11 +178,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase {
|
||||
});
|
||||
|
||||
const abortHandler = () => {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
killProcessByChildProcess(child, false).catch(() => {});
|
||||
resolve(null);
|
||||
};
|
||||
this.abortController.signal.addEventListener('abort', abortHandler);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { logger } from '@/ui/logger';
|
||||
import { restoreTerminalState } from '@/ui/terminalState';
|
||||
import { spawnWithAbort } from '@/utils/spawnWithAbort';
|
||||
import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard';
|
||||
|
||||
export async function geminiLocal(opts: {
|
||||
path: string;
|
||||
@@ -36,23 +35,17 @@ export async function geminiLocal(opts: {
|
||||
|
||||
logger.debug(`[GeminiLocal] Spawning gemini with args: ${JSON.stringify(args)}`);
|
||||
|
||||
process.stdin.pause();
|
||||
try {
|
||||
await spawnWithAbort({
|
||||
command: 'gemini',
|
||||
args,
|
||||
cwd: opts.path,
|
||||
env,
|
||||
signal: opts.abort,
|
||||
shell: process.platform === 'win32',
|
||||
logLabel: 'GeminiLocal',
|
||||
spawnName: 'gemini',
|
||||
installHint: 'Gemini CLI',
|
||||
includeCause: true,
|
||||
logExit: true
|
||||
});
|
||||
} finally {
|
||||
process.stdin.resume();
|
||||
restoreTerminalState();
|
||||
}
|
||||
await spawnWithTerminalGuard({
|
||||
command: 'gemini',
|
||||
args,
|
||||
cwd: opts.path,
|
||||
env,
|
||||
signal: opts.abort,
|
||||
shell: process.platform === 'win32',
|
||||
logLabel: 'GeminiLocal',
|
||||
spawnName: 'gemini',
|
||||
installHint: 'Gemini CLI',
|
||||
includeCause: true,
|
||||
logExit: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { logger } from '@/ui/logger';
|
||||
import { restoreTerminalState } from '@/ui/terminalState';
|
||||
import { spawnWithAbort } from '@/utils/spawnWithAbort';
|
||||
import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard';
|
||||
|
||||
export async function opencodeLocal(opts: {
|
||||
path: string;
|
||||
@@ -15,23 +14,17 @@ export async function opencodeLocal(opts: {
|
||||
|
||||
logger.debug(`[OpencodeLocal] Spawning opencode with args: ${JSON.stringify(args)}`);
|
||||
|
||||
process.stdin.pause();
|
||||
try {
|
||||
await spawnWithAbort({
|
||||
command: 'opencode',
|
||||
args,
|
||||
cwd: opts.path,
|
||||
env: opts.env,
|
||||
signal: opts.abort,
|
||||
shell: process.platform === 'win32',
|
||||
logLabel: 'OpencodeLocal',
|
||||
spawnName: 'opencode',
|
||||
installHint: 'OpenCode CLI',
|
||||
includeCause: true,
|
||||
logExit: true
|
||||
});
|
||||
} finally {
|
||||
process.stdin.resume();
|
||||
restoreTerminalState();
|
||||
}
|
||||
await spawnWithTerminalGuard({
|
||||
command: 'opencode',
|
||||
args,
|
||||
cwd: opts.path,
|
||||
env: opts.env,
|
||||
signal: opts.abort,
|
||||
shell: process.platform === 'win32',
|
||||
logLabel: 'OpencodeLocal',
|
||||
spawnName: 'opencode',
|
||||
installHint: 'OpenCode CLI',
|
||||
includeCause: true,
|
||||
logExit: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -148,3 +148,5 @@ export async function spawnWithAbort(options: SpawnWithAbortOptions): Promise<vo
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('@/ui/terminalState', () => ({
|
||||
restoreTerminalState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/spawnWithAbort', () => ({
|
||||
spawnWithAbort: vi.fn(),
|
||||
}));
|
||||
|
||||
import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard';
|
||||
import { spawnWithAbort } from '@/utils/spawnWithAbort';
|
||||
import { restoreTerminalState } from '@/ui/terminalState';
|
||||
|
||||
const mockSpawnWithAbort = vi.mocked(spawnWithAbort);
|
||||
const mockRestoreTerminalState = vi.mocked(restoreTerminalState);
|
||||
|
||||
const dummyOptions = {
|
||||
command: 'test-agent',
|
||||
args: [],
|
||||
cwd: '/tmp',
|
||||
env: process.env,
|
||||
signal: new AbortController().signal,
|
||||
logLabel: 'Test',
|
||||
spawnName: 'test',
|
||||
installHint: 'Test CLI',
|
||||
};
|
||||
|
||||
describe('spawnWithTerminalGuard', () => {
|
||||
let stdinPauseSpy: ReturnType<typeof vi.spyOn>;
|
||||
let stdinResumeSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
stdinPauseSpy = vi.spyOn(process.stdin, 'pause').mockImplementation(() => process.stdin);
|
||||
stdinResumeSpy = vi.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdinPauseSpy.mockRestore();
|
||||
stdinResumeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('pauses stdin before spawn and resumes after success', async () => {
|
||||
mockSpawnWithAbort.mockResolvedValue();
|
||||
|
||||
await spawnWithTerminalGuard(dummyOptions);
|
||||
|
||||
expect(stdinPauseSpy).toHaveBeenCalledOnce();
|
||||
expect(stdinResumeSpy).toHaveBeenCalledOnce();
|
||||
expect(mockRestoreTerminalState).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('passes options through to spawnWithAbort unchanged', async () => {
|
||||
mockSpawnWithAbort.mockResolvedValue();
|
||||
|
||||
await spawnWithTerminalGuard(dummyOptions);
|
||||
|
||||
expect(mockSpawnWithAbort).toHaveBeenCalledWith(dummyOptions);
|
||||
});
|
||||
|
||||
it('resumes stdin and restores terminal state even when spawn rejects', async () => {
|
||||
mockSpawnWithAbort.mockRejectedValue(new Error('spawn failed'));
|
||||
|
||||
await expect(spawnWithTerminalGuard(dummyOptions)).rejects.toThrow('spawn failed');
|
||||
|
||||
expect(stdinResumeSpy).toHaveBeenCalledOnce();
|
||||
expect(mockRestoreTerminalState).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('propagates the original error from spawnWithAbort', async () => {
|
||||
const error = new Error('process exited with code 1');
|
||||
mockSpawnWithAbort.mockRejectedValue(error);
|
||||
|
||||
await expect(spawnWithTerminalGuard(dummyOptions)).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
it('calls pause before spawn, and resume after spawn', async () => {
|
||||
mockSpawnWithAbort.mockResolvedValue();
|
||||
|
||||
await spawnWithTerminalGuard(dummyOptions);
|
||||
|
||||
expect(stdinPauseSpy.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(mockSpawnWithAbort.mock.invocationCallOrder[0]);
|
||||
expect(mockSpawnWithAbort.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(stdinResumeSpy.mock.invocationCallOrder[0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { restoreTerminalState } from '@/ui/terminalState';
|
||||
import { spawnWithAbort, type SpawnWithAbortOptions } from '@/utils/spawnWithAbort';
|
||||
|
||||
/**
|
||||
* Guards the terminal around a spawnWithAbort call: pauses stdin before spawn,
|
||||
* then resumes stdin and restores terminal escape state in finally.
|
||||
*/
|
||||
export async function spawnWithTerminalGuard(options: SpawnWithAbortOptions): Promise<void> {
|
||||
process.stdin.pause();
|
||||
try {
|
||||
await spawnWithAbort(options);
|
||||
} finally {
|
||||
process.stdin.resume();
|
||||
restoreTerminalState();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user