Implement background command support for bash tool
- Added `background` option to `bash` tool to allow long-lived commands to run without blocking the turn. - Introduced `bash_status` tool to check the status and output of background commands. - Added `bash_stop` tool to explicitly stop running background commands. - Updated command parsing to handle `/bash` commands for listing, stopping individual, and stopping all background commands. - Enhanced session management to reap stale background commands on agent shutdown. - Updated UI to reflect background command status and allow user interruption via ctrl-c. - Added tests for background command functionality and command parsing. - Documented new features and usage in background-bash.md.
This commit is contained in:
+28
@@ -23,6 +23,7 @@ import * as registry from './registry';
|
||||
import { Session } from './session';
|
||||
import { loadCustomCommands } from './custom-commands';
|
||||
import { loadSkills } from './skills';
|
||||
import { reapStaleBackgrounds, shutdownBackgrounds, backgroundSummary, stopBackground } from './tools';
|
||||
import * as store from './store';
|
||||
import { createTaskTool, type SubagentApproval } from './subagent';
|
||||
import { VERSION, versionLine } from './version';
|
||||
@@ -327,6 +328,10 @@ const subagentModel =
|
||||
// wired after construction.
|
||||
let recordSubagent: (usage: { inputTokens: number; outputTokens: number }) => void = () => {};
|
||||
|
||||
// Orphaned background commands from a crashed session: kill them if they are
|
||||
// still alive, so a dev server a dead agent started does not linger.
|
||||
reapStaleBackgrounds();
|
||||
|
||||
const session = new Session({
|
||||
model: languageModel ?? unconfiguredModel,
|
||||
modelId: cfg.model,
|
||||
@@ -398,6 +403,13 @@ async function shutdown(code: number): Promise<never> {
|
||||
clearTimeout(saveTimer);
|
||||
if (session.messages.length > 0) await persist(session.messages);
|
||||
await mcp?.close();
|
||||
// Dev servers and watchers started in the background must not outlive the
|
||||
// agent silently; reap them (best-effort, never blocks exit).
|
||||
try {
|
||||
await shutdownBackgrounds();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
process.exit(code);
|
||||
}
|
||||
const printArg = flag('-p', '--print');
|
||||
@@ -562,6 +574,22 @@ const hooks: AppHooks = {
|
||||
return `removed mcp server ${name}\nsaved to ${path}\nrestart shiro to disconnect it`;
|
||||
},
|
||||
},
|
||||
backgroundCommands: {
|
||||
list: () => {
|
||||
const rows = backgroundSummary();
|
||||
if (rows.length === 0) return 'no background commands';
|
||||
return rows
|
||||
.map((r) => `${r.handle}: ${r.name} — ${r.running ? 'running' : `exit ${r.exit}`}\n ${r.command}`)
|
||||
.join('\n');
|
||||
},
|
||||
stop: async (handle) => stopBackground(handle),
|
||||
stopAll: async () => {
|
||||
const rows = backgroundSummary();
|
||||
if (rows.length === 0) return 'no background commands';
|
||||
const results = await Promise.all(rows.map((r) => stopBackground(r.handle)));
|
||||
return results.join('\n');
|
||||
},
|
||||
},
|
||||
initPrompt: INIT_PROMPT,
|
||||
scaffoldWorkflow: () => scaffoldWorkflowFiles(),
|
||||
history: promptHistory,
|
||||
|
||||
@@ -29,6 +29,7 @@ export type CommandAction =
|
||||
| { type: 'undo' }
|
||||
| { type: 'redo' }
|
||||
| { type: 'changes' }
|
||||
| { type: 'bash'; action: 'list' | 'stop' | 'stop-all'; arg?: string }
|
||||
| { type: 'search'; query: string }
|
||||
| { type: 'fork' }
|
||||
| { type: 'workflow' }
|
||||
@@ -70,6 +71,7 @@ export const COMMANDS: CommandSpec[] = [
|
||||
{ name: 'undo', summary: 'undo the last turn — restores files and conversation (bash effects are not snapshotted)' },
|
||||
{ name: 'redo', summary: 'redo the last undone turn' },
|
||||
{ name: 'changes', summary: 'show what the last turn changed on disk' },
|
||||
{ name: 'bash', arg: '[list|stop <id>|stop all]', summary: 'list or stop background commands started with bash background: true' },
|
||||
{ name: 'search', arg: '<query>', summary: 'search saved sessions for a phrase' },
|
||||
{ name: 'fork', summary: 'fork the session at the last turn boundary (keeps the original)' },
|
||||
{ name: 'workflow', summary: 'show project workflow state: TODO/ROADMAP tracking, docs, nudges' },
|
||||
@@ -243,6 +245,18 @@ export function parseCommand(raw: string, custom: readonly CustomCommand[] = [])
|
||||
return { type: 'redo' };
|
||||
case 'changes':
|
||||
return { type: 'changes' };
|
||||
case 'bash': {
|
||||
const [verb = '', ...rest] = arg.split(/\s+/);
|
||||
if (verb === 'stop') {
|
||||
if (rest.join(' ').trim().toLowerCase() === 'all') return { type: 'bash', action: 'stop-all' };
|
||||
const id = Number(rest[0]);
|
||||
return Number.isInteger(id) && id > 0
|
||||
? { type: 'bash', action: 'stop', arg: String(id) }
|
||||
: { type: 'info', text: 'usage: /bash stop <id> or /bash stop all' };
|
||||
}
|
||||
if (verb && verb !== 'list') return { type: 'info', text: 'usage: /bash [list|stop <id>|stop all]' };
|
||||
return { type: 'bash', action: 'list' };
|
||||
}
|
||||
case 'search':
|
||||
return arg ? { type: 'search', query: arg } : { type: 'info', text: 'usage: /search <query>' };
|
||||
case 'fork':
|
||||
|
||||
+5
-1
@@ -61,6 +61,10 @@ export function subjectOf(tool: string, input: unknown): string | undefined {
|
||||
switch (tool) {
|
||||
case 'bash':
|
||||
return str('command');
|
||||
case 'bash_stop':
|
||||
// Stopping a background command is gated like the command that started it,
|
||||
// so an approved `bash` rule also covers stopping what it started.
|
||||
return str('handle');
|
||||
case 'mcp_call': {
|
||||
const server = str('server');
|
||||
const toolName = str('tool');
|
||||
@@ -220,7 +224,7 @@ function buildDefaults(): PermissionConfig {
|
||||
}
|
||||
} catch {
|
||||
// tests that import permission in isolation still get BASE + known mutating fallback
|
||||
for (const name of ['write_file','edit_file','multi_edit','apply_patch','move_file','delete_file','insert_lines','delete_lines','replace_lines','append_file','prepend_file','bash','mcp_call'] as const) {
|
||||
for (const name of ['write_file','edit_file','multi_edit','apply_patch','move_file','delete_file','insert_lines','delete_lines','replace_lines','append_file','prepend_file','bash','bash_stop','mcp_call'] as const) {
|
||||
if (!(name in out)) (out as Record<string, PermissionEntry>)[name] = 'ask';
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -1004,7 +1004,11 @@ export class Session {
|
||||
// Files written this turn are now on disk; re-walk so the next prompt's
|
||||
// workspace list shows them without a restart.
|
||||
try { await this.refreshWorkspaceFiles(); } catch {}
|
||||
onBashOutput(undefined);
|
||||
// Keep the listener alive while a background process is running so the UI
|
||||
// panel shows its output between turns. statusBackground always has the
|
||||
// data either way (bg.tail); this just keeps the live panel up.
|
||||
const { backgroundHandles } = await import('./tools');
|
||||
if (backgroundHandles().length === 0) onBashOutput(undefined);
|
||||
await (this.pluginHost ?? this.opts.plugins)?.afterTurn();
|
||||
if (!this.opts.disableAutoLearn && this.messages.length >= 6) {
|
||||
this.learnTurns += 1;
|
||||
|
||||
+237
-4
@@ -1,6 +1,6 @@
|
||||
import { tool } from 'ai';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { jail, posix, walk } from './ignore';
|
||||
import { recordBeforeWrite } from './snapshot';
|
||||
@@ -585,6 +585,206 @@ type Running = { command: string; proc: Bun.Subprocess; interrupted: boolean; ki
|
||||
|
||||
const running = new Map<string, Running>();
|
||||
|
||||
/** Best-effort path for the background-process journal, resolved per call (SHIRO_HOME may move). */
|
||||
function bgJournalPath(): string {
|
||||
const home = process.env['SHIRO_HOME'] ?? join(process.env['HOME'] ?? '', '.shiro-neko');
|
||||
return join(home, 'backgrounds.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* A detached background command (dev server, watcher, long test).
|
||||
*
|
||||
* Unlike `running` (foreground, killed by ctrl-c), a background command is
|
||||
* setsid'd so the agent process can exit without taking it down, and ctrl-c in
|
||||
* the agent does not signal it. It lives until `bash_stop`, or until the agent
|
||||
* exits and reaps it (see `shutdownBackgrounds`) — a stray dev server from a
|
||||
* crashed session must not linger, so the journal exists for exactly that case.
|
||||
*/
|
||||
type Background = {
|
||||
handle: number;
|
||||
command: string;
|
||||
name: string;
|
||||
proc: Bun.Subprocess;
|
||||
exited: Promise<number | null>;
|
||||
/** Fresh output since the last `bash_status`. Append-only, capped by MAX_OUTPUT. */
|
||||
tail: string;
|
||||
/** How much of `tail` the last status read already showed; the rest is "new". */
|
||||
shown: number;
|
||||
};
|
||||
|
||||
let nextHandle = 1;
|
||||
const backgrounds = new Map<number, Background>();
|
||||
const BY_NAME = new Map<string, number>();
|
||||
|
||||
export function backgroundHandles(): number[] {
|
||||
return [...backgrounds.keys()].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function backgroundSummary(): { handle: number; name: string; command: string; running: boolean; exit: number | null }[] {
|
||||
return [...backgrounds.values()]
|
||||
.sort((a, b) => a.handle - b.handle)
|
||||
.map((b) => ({
|
||||
handle: b.handle,
|
||||
name: b.name,
|
||||
command: b.command,
|
||||
running: !b.proc.exited,
|
||||
exit: b.proc.exitCode,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a detached background command and returns its handle.
|
||||
*
|
||||
* Output streams into the per-handle tail buffer (and through the bash listener
|
||||
* for live UI progress). The process is adopted by a cleanup crawler at boot:
|
||||
* anything the journal lists that is still alive is killed (see below).
|
||||
*/
|
||||
export function startBackground(command: string, name: string): number {
|
||||
const shell = process.platform === 'win32' ? ['cmd', '/c', command] : ['bash', '-lc', command];
|
||||
let proc: Bun.Subprocess;
|
||||
try {
|
||||
proc = Bun.spawn(shell, {
|
||||
cwd: process.cwd(),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
detached: true,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`could not start background command: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
const handle = nextHandle++;
|
||||
const bg: Background = {
|
||||
handle,
|
||||
command,
|
||||
name: name || command.split(/\s+/)[0] || 'command',
|
||||
proc,
|
||||
exited: proc.exited,
|
||||
tail: '',
|
||||
shown: 0,
|
||||
};
|
||||
backgrounds.set(handle, bg);
|
||||
if (name) BY_NAME.set(name, handle);
|
||||
|
||||
const pump = async (stream: ReadableStream<Uint8Array> | undefined) => {
|
||||
if (!stream) return;
|
||||
const decoder = new TextDecoder();
|
||||
for await (const chunk of stream) {
|
||||
const text = decoder.decode(chunk, { stream: true });
|
||||
if (!text) continue;
|
||||
bg.tail = (bg.tail + text).slice(-MAX_OUTPUT * 2);
|
||||
bashListener?.({ toolCallId: `bg${handle}`, chunk: text });
|
||||
}
|
||||
};
|
||||
void pump(proc.stdout as ReadableStream<Uint8Array>);
|
||||
void pump(proc.stderr as ReadableStream<Uint8Array>);
|
||||
void proc.exited.then(() => {
|
||||
try {
|
||||
writeJournal();
|
||||
} catch {
|
||||
// journal is best-effort
|
||||
}
|
||||
});
|
||||
|
||||
writeJournal();
|
||||
return handle;
|
||||
}
|
||||
|
||||
function writeJournal(): void {
|
||||
try {
|
||||
const dir = dirname(bgJournalPath());
|
||||
const { mkdirSync, writeFileSync } = require('node:fs') as typeof import('node:fs');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const rows = [...backgrounds.values()].map((b) => ({
|
||||
handle: b.handle,
|
||||
pid: b.proc.pid,
|
||||
command: b.command,
|
||||
name: b.name,
|
||||
alive: b.proc.exitCode === null,
|
||||
}));
|
||||
writeFileSync(bgJournalPath(), JSON.stringify(rows, null, 2));
|
||||
} catch {
|
||||
// best-effort; a failed journal write must not break a command start
|
||||
}
|
||||
}
|
||||
|
||||
/** Stale entries from a crashed session: if still alive, kill them (best-effort). */
|
||||
export function reapStaleBackgrounds(): void {
|
||||
try {
|
||||
const { readFileSync, existsSync } = require('node:fs') as typeof import('node:fs');
|
||||
const p = bgJournalPath();
|
||||
if (!existsSync(p)) return;
|
||||
const rows = JSON.parse(readFileSync(p, 'utf8')) as { pid?: number }[];
|
||||
for (const row of rows) {
|
||||
if (typeof row.pid !== 'number' || row.pid <= 0) continue;
|
||||
try {
|
||||
process.kill(row.pid, 0); // throws if the pid is not ours / gone
|
||||
process.kill(row.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// already gone or not ours; nothing to reap
|
||||
}
|
||||
}
|
||||
try {
|
||||
const { unlinkSync } = require('node:fs') as typeof import('node:fs');
|
||||
unlinkSync(p);
|
||||
} catch {
|
||||
// fine
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kills every live background process and clears the journal. Called on agent
|
||||
* shutdown so a dev server started by a session does not outlive it silently.
|
||||
*/
|
||||
export async function shutdownBackgrounds(): Promise<void> {
|
||||
const live = [...backgrounds.values()].filter((b) => b.proc.exitCode === null);
|
||||
const kills = live.map((b) => {
|
||||
b.proc.kill();
|
||||
return b.proc.exited;
|
||||
});
|
||||
await Promise.allSettled(kills);
|
||||
backgrounds.clear();
|
||||
BY_NAME.clear();
|
||||
try {
|
||||
const { unlinkSync } = require('node:fs') as typeof import('node:fs');
|
||||
unlinkSync(bgJournalPath());
|
||||
} catch {
|
||||
// file already gone
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops one background command by handle. Returns a human-readable result. */
|
||||
export async function stopBackground(handle: number): Promise<string> {
|
||||
const bg = backgrounds.get(handle);
|
||||
if (!bg) return `no such handle: ${handle}`;
|
||||
bg.proc.kill();
|
||||
await bg.proc.exited;
|
||||
backgrounds.delete(handle);
|
||||
if (bg.name) BY_NAME.delete(bg.name);
|
||||
writeJournal();
|
||||
return `killed ${handle}: ${bg.command}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The summary a `bash_status` call returns: the process state, recent output,
|
||||
* and whether there is output newer than the last status the model read (so a
|
||||
* poll sees progress without re-reading the whole buffer).
|
||||
*/
|
||||
export function statusBackground(handle: number): string {
|
||||
const bg = backgrounds.get(handle);
|
||||
if (!bg) return `status: no such handle ${handle}`;
|
||||
const lines = bg.tail.split('\n');
|
||||
const shown = bg.shown;
|
||||
const freshLines = lines.slice(Math.max(0, lines.length - shown)).join('\n').trim();
|
||||
bg.shown = lines.length;
|
||||
const state = bg.proc.exitCode === null ? 'running' : `finished (exit ${bg.proc.exitCode})`;
|
||||
const fresh = freshLines.length > 0 ? `\nnew output:\n${freshLines}` : '\n(new output: none)';
|
||||
return `handle ${handle}: ${bg.name}\nstatus: ${state}${bg.command ? `\ncommand: ${bg.command}` : ''}${fresh}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kills the shell and everything it started.
|
||||
*
|
||||
@@ -633,12 +833,22 @@ export function interruptBash(): string[] {
|
||||
export const bashTool = withMeta({ set: 'core', mutating: true }, tool({
|
||||
description:
|
||||
'Run a shell command in the workspace root. Use for builds, tests, git, and package managers. ' +
|
||||
'Output streams live and the user can interrupt a command with ctrl-c without ending the turn.',
|
||||
'Output streams live and the user can interrupt a command with ctrl-c without ending the turn. ' +
|
||||
'For a command that does not exit — a dev server, a watcher, a long-running test — set background: true ' +
|
||||
'so the tool returns immediately with a handle; poll it with bash_status and stop it with bash_stop when done. ' +
|
||||
'Always stop what you start.',
|
||||
inputSchema: z.object({
|
||||
command: z.string(),
|
||||
timeout: z.number().int().min(1000).max(600_000).optional().describe('Timeout in ms, default 120000'),
|
||||
timeout: z.number().int().min(1000).max(600_000).optional().describe('Timeout in ms, default 120000; ignored when background is true'),
|
||||
background: z.boolean().optional().describe('Detach the command and return immediately with a handle. Use for dev servers and watchers that never exit'),
|
||||
name: z.string().optional().describe('Label for a background command, shown in /bash and bash_status. Ignored unless background is true'),
|
||||
}),
|
||||
execute: async ({ command, timeout = 120_000 }, { toolCallId, abortSignal }) => {
|
||||
execute: async ({ command, timeout = 120_000, background, name }, { toolCallId, abortSignal }) => {
|
||||
if (background) {
|
||||
const handle = startBackground(command, name ?? '');
|
||||
const pid = [...backgrounds.values()].find((b) => b.handle === handle)?.proc.pid;
|
||||
return `background ${handle}: running (pid ${pid ?? '?'}) — poll with bash_status handle=${handle}, stop with bash_stop handle=${handle}\ncommand: ${command}`;
|
||||
}
|
||||
const shell = process.platform === 'win32' ? ['cmd', '/c', command] : ['bash', '-lc', command];
|
||||
const proc = Bun.spawn(shell, {
|
||||
cwd: process.cwd(),
|
||||
@@ -694,6 +904,27 @@ export const bashTool = withMeta({ set: 'core', mutating: true }, tool({
|
||||
},
|
||||
}));
|
||||
|
||||
export const bashStatusTool = withMeta({ set: 'core', mutating: false }, tool({
|
||||
description:
|
||||
'Check a background command started with bash background: true. Pass the handle from that call. ' +
|
||||
'Returns whether it is still running or finished (with its exit code) and any output produced since the last status. ' +
|
||||
'Poll this while working; stop the command with bash_stop when it has served its purpose.',
|
||||
inputSchema: z.object({
|
||||
handle: z.number().int().positive().describe('Handle returned by a background bash call'),
|
||||
}),
|
||||
execute: async ({ handle }) => statusBackground(handle),
|
||||
}));
|
||||
|
||||
export const bashStopTool = withMeta({ set: 'core', mutating: true }, tool({
|
||||
description:
|
||||
'Stop a background command started with bash background: true. Pass the handle from that call. ' +
|
||||
'Use this when the command has done its job (a dev server you no longer need, a watcher, a long test).',
|
||||
inputSchema: z.object({
|
||||
handle: z.number().int().positive().describe('Handle returned by a background bash call'),
|
||||
}),
|
||||
execute: async ({ handle }) => stopBackground(handle),
|
||||
}));
|
||||
|
||||
export const moveFileTool = withMeta({ set: 'edit-plus', mutating: true }, tool({
|
||||
description:
|
||||
'Move or rename one file. Creates the target directory. Refuses if the source is missing or the target ' +
|
||||
@@ -869,6 +1100,8 @@ export const tools = {
|
||||
find_symbol: findSymbolTool,
|
||||
json_query: jsonQueryTool,
|
||||
bash: bashTool,
|
||||
bash_status: bashStatusTool,
|
||||
bash_stop: bashStopTool,
|
||||
...gitTools,
|
||||
...netTools,
|
||||
...extraTools,
|
||||
|
||||
+45
-4
@@ -93,6 +93,12 @@ export type AppHooks = {
|
||||
initPrompt: string;
|
||||
/** Directly scaffold TODO.md / ROADMAP.md / docs/ when they are missing; returns what was written. */
|
||||
scaffoldWorkflow: () => string[];
|
||||
/** Background commands started with bash background: true — list, stop one, stop all. */
|
||||
backgroundCommands: {
|
||||
list: () => string;
|
||||
stop: (handle: number) => Promise<string>;
|
||||
stopAll: () => Promise<string>;
|
||||
};
|
||||
history: string[];
|
||||
recordPrompt: (text: string) => void;
|
||||
};
|
||||
@@ -278,12 +284,28 @@ export function App({
|
||||
// ctrl-c kills only the command in flight, leaving the turn alive so the model
|
||||
// gets a tool error and can decide what to do. With nothing running it keeps its
|
||||
// usual meaning and quits, which is why Ink's own ctrl-c handling is turned off
|
||||
// in cli.tsx rather than left to race with this.
|
||||
useInput((input, key) => {
|
||||
// in cli.tsx rather than left to race with this. When a background command is
|
||||
// running (started by the model with bash background: true) and nothing
|
||||
// foreground is in flight, ctrl-c stops the most recently started one instead
|
||||
// of quitting — an accidental quit killing a dev server the user still wants.
|
||||
useInput(async (input, key) => {
|
||||
if (!key.ctrl || input !== 'c') return;
|
||||
const killed = interruptBash();
|
||||
if (killed.length === 0) return exit();
|
||||
push({ kind: 'info', text: `interrupted: ${killed.join(', ')}` });
|
||||
if (killed.length > 0) {
|
||||
push({ kind: 'info', text: `interrupted: ${killed.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
const handles = hooks.backgroundCommands.list();
|
||||
if (handles.trim() !== '' && handles.trim() !== 'no background commands') {
|
||||
const lines = handles.split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
const first = lines[0]?.match(/^(\d+):/)?.[1];
|
||||
if (first) {
|
||||
const msg = await hooks.backgroundCommands.stop(Number(first));
|
||||
push({ kind: 'info', text: `ctrl-c: no foreground command; ${msg}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
return exit();
|
||||
});
|
||||
|
||||
useInput(
|
||||
@@ -749,6 +771,25 @@ export function App({
|
||||
setPanel(workflowPanel(session));
|
||||
return;
|
||||
}
|
||||
case 'bash': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
if (action.action === 'list') {
|
||||
push({ kind: 'info', text: hooks.backgroundCommands.list() });
|
||||
return;
|
||||
}
|
||||
setWorking(true);
|
||||
try {
|
||||
const text =
|
||||
action.action === 'stop-all'
|
||||
? await hooks.backgroundCommands.stopAll()
|
||||
: await hooks.backgroundCommands.stop(Number(action.arg));
|
||||
push({ kind: 'info', text });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
setWorking(false);
|
||||
return;
|
||||
}
|
||||
case 'provider':
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setOnboarding(true);
|
||||
|
||||
Reference in New Issue
Block a user