From 2ec90fe0863d401e661783bb9129eb1fe5875c89 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 29 Mar 2026 18:44:37 +0900 Subject: [PATCH] feat: add /stop command to kill running agent process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /stop, /cancel, /kill aliases all supported - SIGTERM first (allows graceful AbortController cleanup), SIGKILL after 5s - Wired through session-commands → GroupQueue.killProcess() --- src/group-queue.ts | 37 ++++++++++++++++++++++++++++++++++++ src/message-runtime.ts | 2 +- src/session-commands.test.ts | 1 + src/session-commands.ts | 14 ++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/group-queue.ts b/src/group-queue.ts index bf940cf..6048899 100644 --- a/src/group-queue.ts +++ b/src/group-queue.ts @@ -503,6 +503,43 @@ export class GroupQueue { } } + /** + * Immediately kill the running agent process for a group. + * Returns true if a process was killed, false if nothing was running. + */ + killProcess(groupJid: string): boolean { + const state = this.getGroup(groupJid); + const proc = state.process; + if (!proc || !this.isProcessAlive(proc)) { + return false; + } + logger.info( + { + groupJid, + runId: state.currentRunId, + processName: state.processName, + }, + 'Killing agent process via /stop command', + ); + try { + // SIGTERM allows the runner to call AbortController.abort() for graceful cleanup. + // Falls back to SIGKILL after 5 seconds if the process doesn't exit. + proc.kill('SIGTERM'); + setTimeout(() => { + if (this.isProcessAlive(proc)) { + try { + proc.kill('SIGKILL'); + } catch { + /* already dead */ + } + } + }, 5000); + } catch { + /* already dead */ + } + return true; + } + private async runForGroup( groupJid: string, reason: 'messages' | 'drain', diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 69bda21..cb98403 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -581,6 +581,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { }); return formatRoomReviewReadyMessage(result); }, + killProcess: () => deps.queue.killProcess(chatJid), }, }); if (cmdResult.handled) return cmdResult.success; @@ -917,7 +918,6 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { ); } } - } }; diff --git a/src/session-commands.test.ts b/src/session-commands.test.ts index 3614161..6f7a473 100644 --- a/src/session-commands.test.ts +++ b/src/session-commands.test.ts @@ -176,6 +176,7 @@ function makeDeps( isAdminSender: vi.fn().mockReturnValue(false), canSenderInteract: vi.fn().mockReturnValue(true), markReviewReady: vi.fn().mockResolvedValue('Review snapshot updated.'), + killProcess: vi.fn().mockReturnValue(false), ...overrides, }; } diff --git a/src/session-commands.ts b/src/session-commands.ts index 8d8a987..b5dc0bf 100644 --- a/src/session-commands.ts +++ b/src/session-commands.ts @@ -34,6 +34,7 @@ export function extractSessionCommand( if (text === '/compact') return '/compact'; if (text === '/clear') return '/clear'; if (text === '/review' || text === '/review-ready') return '/review'; + if (text === '/stop' || text === '/cancel' || text === '/kill') return '/stop'; return null; } @@ -79,6 +80,8 @@ export interface SessionCommandDeps { /** Whether the denied sender would normally be allowed to interact (for denial messages). */ canSenderInteract: (msg: NewMessage) => boolean; markReviewReady: () => Promise; + /** Kill the currently running agent process for this group. Returns true if a process was killed. */ + killProcess: () => boolean; } function resultToText(result: string | object | null | undefined): string { @@ -173,6 +176,17 @@ export async function handleSessionCommand(opts: { return { handled: true, success: true }; } + if (command === '/stop') { + const killed = deps.killProcess(); + deps.advanceCursor(cmdMsg.timestamp); + await deps.sendMessage( + killed + ? 'Agent stopped.' + : 'No agent is currently running in this room.', + ); + return { handled: true, success: true }; + } + const cmdIndex = missedMessages.indexOf(cmdMsg); const preCompactMsgs = missedMessages.slice(0, cmdIndex);