diff --git a/src/adapters/base-adapter.ts b/src/adapters/base-adapter.ts index 9d0ae65..94a3c7f 100644 --- a/src/adapters/base-adapter.ts +++ b/src/adapters/base-adapter.ts @@ -1,5 +1,6 @@ import { spawn, ChildProcess } from "child_process"; import { logger } from "../shared/logger"; +import { killProcessTree } from "../shared/process-kill"; import { IDEAdapter, ModelInfo, TrackedFiles } from "../shared/types"; export interface AdapterConfig { @@ -66,6 +67,7 @@ export abstract class BaseAdapter implements IDEAdapter { protected connected: boolean = false; protected projectPath: string; protected currentProcess: ChildProcess | null = null; + private _intentionalKill: boolean = false; private modifiedFiles: Set = new Set(); private readFiles: Set = new Set(); @@ -122,7 +124,8 @@ export abstract class BaseAdapter implements IDEAdapter { async disconnect(): Promise { if (this.currentProcess) { - this.currentProcess.kill("SIGTERM"); + this._intentionalKill = true; + this.killProcess(this.currentProcess); this.currentProcess = null; } this.connected = false; @@ -131,6 +134,7 @@ export abstract class BaseAdapter implements IDEAdapter { abort(): void { if (this.currentProcess) { logger.debug("Aborting current process..."); + this._intentionalKill = true; this.killProcess(this.currentProcess); this.currentProcess = null; } @@ -161,6 +165,7 @@ export abstract class BaseAdapter implements IDEAdapter { const abortHandler = () => { if (this.currentProcess) { logger.debug("Aborting command execution..."); + this._intentionalKill = true; this.killProcess(this.currentProcess); this.currentProcess = null; } @@ -236,7 +241,9 @@ export abstract class BaseAdapter implements IDEAdapter { this.currentProcess = null; const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - if (sig === "SIGTERM") { + const wasIntentional = this._intentionalKill || sig === "SIGTERM"; + this._intentionalKill = false; + if (wasIntentional) { reject(new Error("Process terminated")); return; } @@ -483,27 +490,8 @@ export abstract class BaseAdapter implements IDEAdapter { } private killProcess(proc: ChildProcess): void { - const isWindows = process.platform === "win32"; - - if (isWindows && proc.pid) { - try { - const { execSync } = require("child_process"); - execSync(`taskkill /pid ${proc.pid} /T /F`, { stdio: "ignore" }); - logger.debug(`Killed process tree ${proc.pid} with taskkill`); - } catch (error) { - logger.debug(`taskkill failed, trying SIGKILL: ${error}`); - proc.kill("SIGKILL"); - } - } else { - proc.kill("SIGTERM"); - setTimeout(() => { - try { - proc.kill("SIGKILL"); - } catch { - // process already exited - } - }, 100); - } + killProcessTree(proc); + logger.debug(`Killed process ${proc.pid ?? "?"}`); } private filterAndLogOutput(text: string, statusKeywords: string[]): void { diff --git a/src/adapters/opencode.ts b/src/adapters/opencode.ts index 1f25b8b..4ac8df9 100644 --- a/src/adapters/opencode.ts +++ b/src/adapters/opencode.ts @@ -25,7 +25,11 @@ export class OpenCodeAdapter extends BaseAdapter { "Please install OpenCode CLI first:\n" + " curl -fsSL https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install | bash\n" + "Or using Homebrew:\n" + - " brew install opencode-ai/tap/opencode", + " brew install opencode-ai/tap/opencode\n" + + "On Windows (PowerShell):\n" + + " irm https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install.ps1 | iex\n" + + "Or using Scoop:\n" + + " scoop install opencode", statusKeywords: ["opencode", "tokens", "completed", "success"], stdinMode: "inherit", }; diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index c2edfbf..79bf442 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -710,10 +710,23 @@ export async function authCommand() { // Set strict file permissions try { - fs.chmodSync(CONFIG_DIR, 0o700); - fs.chmodSync(CONFIG_FILE, 0o600); + if (process.platform === "win32") { + const { execSync } = require("child_process"); + const user = process.env.USERNAME || process.env.USER || ""; + if (user) { + execSync(`icacls "${CONFIG_DIR}" /inheritance:r /grant:r "${user}:(OI)(CI)F" /T`, { + stdio: "ignore", + }); + execSync(`icacls "${CONFIG_FILE}" /inheritance:r /grant:r "${user}:F"`, { + stdio: "ignore", + }); + } + } else { + fs.chmodSync(CONFIG_DIR, 0o700); + fs.chmodSync(CONFIG_FILE, 0o600); + } } catch { - // Windows doesn't support chmod + // Permissions could not be set — non-critical } console.log(chalk.green("\nAuthentication successful!")); diff --git a/src/platforms/telegram.ts b/src/platforms/telegram.ts index 467db22..b72d486 100644 --- a/src/platforms/telegram.ts +++ b/src/platforms/telegram.ts @@ -273,7 +273,10 @@ export class TelegramBot { logger.info("Telegram bot is running!"); process.once("SIGINT", () => this.bot.stop("SIGINT")); - process.once("SIGTERM", () => this.bot.stop("SIGTERM")); + if (process.platform !== "win32") { + process.once("SIGTERM", () => this.bot.stop("SIGTERM")); + } + process.once("beforeExit", () => this.bot.stop("beforeExit")); } } diff --git a/src/shared/process-kill.ts b/src/shared/process-kill.ts new file mode 100644 index 0000000..b7163b5 --- /dev/null +++ b/src/shared/process-kill.ts @@ -0,0 +1,50 @@ +import { ChildProcess } from "child_process"; + +/** + * Cross-platform process termination. + * - Windows: uses taskkill /T /F to kill process tree, falls back to proc.kill() + * - Unix: sends SIGTERM, escalates to SIGKILL after graceMs + */ +export function killProcessTree(proc: ChildProcess, graceMs: number = 100): void { + if (process.platform === "win32" && proc.pid) { + try { + const { execSync } = require("child_process"); + execSync(`taskkill /pid ${proc.pid} /T /F`, { stdio: "ignore" }); + } catch { + try { + proc.kill(); + } catch {} + } + } else { + try { + proc.kill("SIGTERM"); + } catch {} + setTimeout(() => { + try { + proc.kill("SIGKILL"); + } catch {} + }, graceMs); + } +} + +/** + * Force-kill a process immediately (no grace period). + * - Windows: taskkill /T /F + * - Unix: SIGKILL + */ +export function forceKillProcess(proc: ChildProcess): void { + if (process.platform === "win32" && proc.pid) { + try { + const { execSync } = require("child_process"); + execSync(`taskkill /pid ${proc.pid} /T /F`, { stdio: "ignore" }); + } catch { + try { + proc.kill(); + } catch {} + } + } else { + try { + proc.kill("SIGKILL"); + } catch {} + } +} diff --git a/src/tools/cron.ts b/src/tools/cron.ts index 9cc7ba6..4327ac4 100644 --- a/src/tools/cron.ts +++ b/src/tools/cron.ts @@ -1,4 +1,4 @@ -import { execFile, exec } from "child_process"; +import { execFile, spawn } from "child_process"; import { Tool, ToolDefinition, ToolResult } from "./types"; const CMD_TIMEOUT = 15_000; @@ -19,18 +19,34 @@ function runCommand( }); } -function runShell( - command: string, +function writeCrontab( + content: string, timeout: number = CMD_TIMEOUT, -): Promise<{ stdout: string; stderr: string; code: number | null }> { +): Promise<{ stderr: string; code: number | null }> { return new Promise((resolve) => { - exec(command, { timeout, maxBuffer: 512 * 1024 }, (err, stdout, stderr) => { - resolve({ - stdout: stdout?.toString() ?? "", - stderr: stderr?.toString() ?? "", - code: err ? ((err as { code?: number }).code ?? 1) : 0, - }); + const proc = spawn("crontab", ["-"], { stdio: ["pipe", "ignore", "pipe"] }); + let stderr = ""; + const timer = setTimeout(() => { + proc.kill(); + resolve({ stderr: "crontab write timed out", code: 1 }); + }, timeout); + + proc.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); }); + + proc.on("close", (code) => { + clearTimeout(timer); + resolve({ stderr, code }); + }); + + proc.on("error", (err) => { + clearTimeout(timer); + resolve({ stderr: err.message, code: 1 }); + }); + + proc.stdin.write(content); + proc.stdin.end(); }); } @@ -265,7 +281,7 @@ export class CronTool implements Tool { const currentCrontab = existing.code === 0 ? existing.stdout : ""; const newCrontab = currentCrontab.trimEnd() + "\n" + cronLine + "\n"; - const result = await runShell(`echo '${newCrontab.replace(/'/g, "'\\''")}' | crontab -`); + const result = await writeCrontab(newCrontab); if (result.code !== 0) { return { toolCallId: "", @@ -321,8 +337,8 @@ export class CronTool implements Tool { return { toolCallId: "", output: `No crontab entry matching "${name}".`, isError: false }; } - const newCrontab = filtered.join("\n"); - const result = await runShell(`echo '${newCrontab.replace(/'/g, "'\\''")}' | crontab -`); + const newCrontab = filtered.join("\n") + "\n"; + const result = await writeCrontab(newCrontab); if (result.code !== 0) { return { toolCallId: "", diff --git a/src/tools/git.ts b/src/tools/git.ts index acf4499..41fff61 100644 --- a/src/tools/git.ts +++ b/src/tools/git.ts @@ -1,4 +1,5 @@ import { execFile } from "child_process"; +import { killProcessTree } from "../shared/process-kill"; import { Tool, ToolDefinition, ToolResult } from "./types"; const BLOCKED_PATTERNS = [ @@ -31,7 +32,7 @@ function runGit( if (signal) { const handler = () => { - proc.kill("SIGTERM"); + killProcessTree(proc); }; signal.addEventListener("abort", handler, { once: true }); proc.on("exit", () => signal.removeEventListener("abort", handler)); diff --git a/src/tools/network.ts b/src/tools/network.ts index bc2a768..a1c9553 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -92,9 +92,11 @@ export class NetworkTool implements Tool { } const isWindows = process.platform === "win32"; + const isMac = process.platform === "darwin"; + const timeoutVal = isMac ? "5000" : "5"; const pingArgs = isWindows ? ["-n", String(count), target] - : ["-c", String(count), "-W", "5", target]; + : ["-c", String(count), "-W", timeoutVal, target]; const result = await runCommand("ping", pingArgs, PING_TIMEOUT + count * 2000); const output = (result.stdout + result.stderr).trim(); diff --git a/src/tools/process.ts b/src/tools/process.ts index 3f34006..a541337 100644 --- a/src/tools/process.ts +++ b/src/tools/process.ts @@ -1,3 +1,4 @@ +import { killProcessTree, forceKillProcess } from "../shared/process-kill"; import { getSession, getFinishedSession, @@ -195,20 +196,23 @@ export class ProcessTool implements Tool { if (running.child) { try { - running.child.kill("SIGTERM"); + killProcessTree(running.child, 3000); setTimeout(() => { if (!running.exited && running.child) { - try { - running.child.kill("SIGKILL"); - } catch {} + forceKillProcess(running.child); } }, 3000); } catch {} } + const killMsg = + process.platform === "win32" + ? `Terminating session ${sessionId} (pid ${running.pid ?? "?"}) via taskkill.` + : `Sent SIGTERM to session ${sessionId} (pid ${running.pid ?? "?"}). Will force-kill in 3s if still alive.`; + return { toolCallId: "", - output: `Sent SIGTERM to session ${sessionId} (pid ${running.pid ?? "?"}). Will force-kill in 3s if still alive.`, + output: killMsg, isError: false, }; } diff --git a/src/tools/sysinfo.ts b/src/tools/sysinfo.ts index 6cfd415..91da3ed 100644 --- a/src/tools/sysinfo.ts +++ b/src/tools/sysinfo.ts @@ -207,25 +207,21 @@ export class SysinfoTool implements Tool { }; } - const output = await runCommand("df", [ - "-h", - "--type=ext4", - "--type=xfs", - "--type=btrfs", - "--type=apfs", - "--type=hfs", - ]); - if (!output) { - // Fallback without type filter (macOS df doesn't support --type) - const fallback = await runCommand("df", ["-h"]); - return { - toolCallId: "", - output: fallback || "Could not retrieve disk info.", - isError: !fallback, - }; + const isMac = process.platform === "darwin"; + const dfArgs = isMac + ? ["-h"] + : ["-h", "--type=ext4", "--type=xfs", "--type=btrfs", "--type=apfs", "--type=hfs"]; + + let output = await runCommand("df", dfArgs); + if (!output && !isMac) { + output = await runCommand("df", ["-h"]); } - return { toolCallId: "", output, isError: false }; + return { + toolCallId: "", + output: output || "Could not retrieve disk info.", + isError: !output, + }; } private actionUptime(): ToolResult { @@ -260,7 +256,9 @@ export class SysinfoTool implements Tool { }; } - const output = await runCommand("ps", ["aux", "--sort=-%cpu"]); + const isMac = process.platform === "darwin"; + const psArgs = isMac ? ["aux", "-r"] : ["aux", "--sort=-%cpu"]; + const output = await runCommand("ps", psArgs); if (!output) { return { toolCallId: "", output: "Could not retrieve process list.", isError: true }; diff --git a/src/tools/terminal.ts b/src/tools/terminal.ts index 66af272..5a25279 100644 --- a/src/tools/terminal.ts +++ b/src/tools/terminal.ts @@ -1,4 +1,5 @@ import { spawn, ChildProcessWithoutNullStreams } from "child_process"; +import { killProcessTree, forceKillProcess } from "../shared/process-kill"; import { createSession, appendOutput, @@ -304,11 +305,10 @@ export class TerminalTool implements Tool { const abortHandler = () => { if (!processExited) { - try { - proc.kill("SIGTERM"); - } catch {} + cleanupAbortListener(); + killProcessTree(proc); if (!yielded) { - markExited(session, null, "SIGTERM", "killed"); + markExited(session, null, null, "killed"); resolve({ status: "failed", exitCode: null, @@ -317,7 +317,7 @@ export class TerminalTool implements Tool { timedOut: false, }); } else { - markExited(session, null, "SIGTERM", "killed"); + markExited(session, null, null, "killed"); } } }; @@ -336,11 +336,9 @@ export class TerminalTool implements Tool { const killTimer = setTimeout(() => { if (!processExited) { cleanupAbortListener(); - try { - proc.kill("SIGKILL"); - } catch {} + forceKillProcess(proc); if (!yielded) { - markExited(session, null, "SIGKILL", "killed"); + markExited(session, null, null, "killed"); resolve({ status: "failed", exitCode: null, @@ -350,7 +348,7 @@ export class TerminalTool implements Tool { timedOut: true, }); } else { - markExited(session, null, "SIGKILL", "killed"); + markExited(session, null, null, "killed"); } } }, timeoutMs);