Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 11 additions & 23 deletions src/adapters/base-adapter.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<string> = new Set();
private readFiles: Set<string> = new Set();

Expand Down Expand Up @@ -122,7 +124,8 @@ export abstract class BaseAdapter implements IDEAdapter {

async disconnect(): Promise<void> {
if (this.currentProcess) {
this.currentProcess.kill("SIGTERM");
this._intentionalKill = true;
this.killProcess(this.currentProcess);
this.currentProcess = null;
}
this.connected = false;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion src/adapters/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
Expand Down
19 changes: 16 additions & 3 deletions src/cli/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!"));
Expand Down
5 changes: 4 additions & 1 deletion src/platforms/telegram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
}

Expand Down
50 changes: 50 additions & 0 deletions src/shared/process-kill.ts
Original file line number Diff line number Diff line change
@@ -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 {}
}
}
42 changes: 29 additions & 13 deletions src/tools/cron.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
});
}

Expand Down Expand Up @@ -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: "",
Expand Down Expand Up @@ -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: "",
Expand Down
3 changes: 2 additions & 1 deletion src/tools/git.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execFile } from "child_process";
import { killProcessTree } from "../shared/process-kill";
import { Tool, ToolDefinition, ToolResult } from "./types";

const BLOCKED_PATTERNS = [
Expand Down Expand Up @@ -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));
Expand Down
4 changes: 3 additions & 1 deletion src/tools/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
14 changes: 9 additions & 5 deletions src/tools/process.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { killProcessTree, forceKillProcess } from "../shared/process-kill";
import {
getSession,
getFinishedSession,
Expand Down Expand Up @@ -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,
};
}
Expand Down
34 changes: 16 additions & 18 deletions src/tools/sysinfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
Expand Down
Loading