chat: point OpenCode at a fixed pm2-managed server (fixes empty model picker)
The per-cwd `opencode serve` spawning is replaced by a single fixed server (http://127.0.0.1:4096, OPENCODE_SERVER_URL) managed by pm2 — added as `officer-opencode` in ecosystem.config.cjs (cwd = home). Root-cause fix for the empty model selector: list-models shelled out to `opencode models`, which failed at runtime on the deployed server (the picker got only Claude tiers). It now reads the fixed server's GET /config/providers over HTTP — 11ms and reliable — so the curated OpenCode models (Big Pickle, Claude Haiku) show up. - server-manager.ts — drops spawning; exposes OPENCODE_SERVER_URL + a health check. - client.ts — createSession no longer binds a directory (sessions live in the one server's project). - send-opencode.ts / opencode-sessions.ts / chat.ts — use the fixed server; drop the cwd/home plumbing. Session list/load/delete/rename now hit :4096. Verified end-to-end against the live server: model list, streaming turn, session list, and transcript load all work with no spawning. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
const { homedir } = require('os');
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
@@ -6,6 +8,15 @@ module.exports = {
|
||||
args: 'start',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
// The fixed OpenCode server the chat harness talks to (http://127.0.0.1:4096).
|
||||
name: 'officer-opencode',
|
||||
script: homedir() + '/.opencode/bin/opencode',
|
||||
args: 'serve --port 4096 --hostname 127.0.0.1',
|
||||
cwd: homedir(),
|
||||
env: { HOME: homedir() },
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-claude',
|
||||
script: 'bun',
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from './opencode-sessions';
|
||||
import { listChatModels } from './list-models';
|
||||
import { logger } from './logger';
|
||||
import { getHomeDirForRole } from '../../data-path';
|
||||
import { readSttConfig } from '../server-settings/stt';
|
||||
import { transcribeAudio } from '../stt/transcribe';
|
||||
|
||||
@@ -26,11 +25,9 @@ export const chatRouter = createRouter();
|
||||
|
||||
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
|
||||
// claude_sessions dir. Claude groups sessions by cwd, so this selects which project group we read.
|
||||
// (OpenCode sessions all live in the one fixed server and ignore cwd.)
|
||||
const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getClaudeSessionsCwd(email);
|
||||
|
||||
// The home dir an OpenCode `serve` runs under (so it reads the user's ~/.local/share/opencode auth).
|
||||
const homeOf = (ctx: Context): string => getHomeDirForRole(ctx.get('user').email, ctx.get('user').role);
|
||||
|
||||
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
||||
chatRouter.get('/pwds', (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
@@ -43,7 +40,7 @@ chatRouter.get('/sessions', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const cwd = cwdOf(ctx, email);
|
||||
const claude = listClaudeSessions(email, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
||||
const opencode = await listOpenCodeSessions(cwd, homeOf(ctx));
|
||||
const opencode = await listOpenCodeSessions();
|
||||
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
return ctx.json({ sessions });
|
||||
});
|
||||
@@ -53,9 +50,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, email);
|
||||
const detail = isOpenCodeSessionId(id)
|
||||
? await loadOpenCodeSession(cwd, homeOf(ctx), id)
|
||||
: loadClaudeSession(email, cwd, id);
|
||||
const detail = isOpenCodeSessionId(id) ? await loadOpenCodeSession(id) : loadClaudeSession(email, cwd, id);
|
||||
if (!detail) return ctx.text('Not found', 404);
|
||||
return ctx.json(detail);
|
||||
});
|
||||
@@ -65,9 +60,7 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const id = ctx.req.param('id');
|
||||
const cwd = cwdOf(ctx, email);
|
||||
const ok = isOpenCodeSessionId(id)
|
||||
? await deleteOpenCodeSession(cwd, homeOf(ctx), id)
|
||||
: deleteClaudeSession(email, cwd, id);
|
||||
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(email, cwd, id);
|
||||
if (!ok) return ctx.text('Not found', 404);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -80,7 +73,7 @@ chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
||||
const { title } = await ctx.req.json<{ title?: string }>();
|
||||
if (!title?.trim()) return ctx.text('title is required', 400);
|
||||
const ok = isOpenCodeSessionId(id)
|
||||
? await renameOpenCodeSession(cwd, homeOf(ctx), id, title.trim())
|
||||
? await renameOpenCodeSession(id, title.trim())
|
||||
: renameClaudeSession(email, cwd, id, title.trim());
|
||||
if (!ok) return ctx.text('Not found', 404);
|
||||
return ctx.json({ ok: true });
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ModelInfo } from './types';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { OPENCODE_SERVER_URL } from './opencode/server-manager';
|
||||
|
||||
// The Claude harness runs the `claude` CLI, so its tiers are a fixed set.
|
||||
const CLAUDE_CODE_MODELS: ModelInfo[] = [
|
||||
@@ -9,46 +8,47 @@ const CLAUDE_CODE_MODELS: ModelInfo[] = [
|
||||
{ id: 'claude-code/haiku', name: 'haiku', provider: 'claude-code', contextWindow: 200000, maxTokens: 8192, reasoning: false, images: true },
|
||||
];
|
||||
|
||||
const OPENCODE_BIN = process.env.OPENCODE_BIN || join(homedir(), '.opencode', 'bin', 'opencode');
|
||||
|
||||
// Curated OpenCode models to surface in the picker (the full `opencode models` catalog is ~58 entries).
|
||||
// Curated OpenCode models to surface in the picker (the full catalog is ~58 entries).
|
||||
const OPENCODE_ALLOWLIST = new Set(['opencode/big-pickle', 'opencode/claude-haiku-4-5']);
|
||||
|
||||
// Cache the OpenCode catalog; it's stable for a session and `opencode models` costs a subprocess.
|
||||
// Cache the OpenCode catalog; it's stable for a session.
|
||||
let openCodeCache: ModelInfo[] | null = null;
|
||||
|
||||
export function invalidateModelCache(): void {
|
||||
openCodeCache = null;
|
||||
}
|
||||
|
||||
// Enumerate OpenCode models via `opencode models` (ids are `providerID/modelID`, e.g.
|
||||
// `opencode/claude-opus-4-8`). Metadata (context/tokens/modalities) is left at neutral defaults for
|
||||
// now — it can be enriched later from a serve's GET /config/providers.
|
||||
type ProvidersResponse = {
|
||||
providers?: Array<{ id?: string; models?: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
// Enumerate OpenCode models from the fixed server's GET /config/providers (reliable — no subprocess).
|
||||
// Model ids are `providerID/modelID` (e.g. `opencode/claude-haiku-4-5`); metadata is left at neutral
|
||||
// defaults for now.
|
||||
async function listOpenCodeModels(): Promise<ModelInfo[]> {
|
||||
if (openCodeCache) return openCodeCache;
|
||||
try {
|
||||
const proc = Bun.spawn([OPENCODE_BIN, 'models'], { stdout: 'pipe', stderr: 'ignore' });
|
||||
const out = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
const res = await fetch(`${OPENCODE_SERVER_URL}/config/providers`, { signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as ProvidersResponse;
|
||||
|
||||
const models: ModelInfo[] = out
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => OPENCODE_ALLOWLIST.has(line))
|
||||
.map((full) => {
|
||||
const slash = full.indexOf('/');
|
||||
return {
|
||||
const models: ModelInfo[] = [];
|
||||
for (const provider of data.providers ?? []) {
|
||||
const providerId = provider.id ?? '';
|
||||
for (const modelId of Object.keys(provider.models ?? {})) {
|
||||
const full = `${providerId}/${modelId}`;
|
||||
if (!OPENCODE_ALLOWLIST.has(full)) continue;
|
||||
models.push({
|
||||
id: full,
|
||||
name: full.slice(slash + 1),
|
||||
provider: full.slice(0, slash),
|
||||
name: modelId,
|
||||
provider: providerId,
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
reasoning: false,
|
||||
images: true,
|
||||
} satisfies ModelInfo;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
openCodeCache = models;
|
||||
return models;
|
||||
|
||||
@@ -3,15 +3,15 @@ import { ensureServer } from './opencode/server-manager';
|
||||
import { getConnection } from './opencode/client';
|
||||
import { logger } from './logger';
|
||||
|
||||
// The OpenCode analog of claude-sessions.ts. OpenCode's own SQLite store is the source of truth, read
|
||||
// via its HTTP API (never the DB directly). A `serve` is directory-scoped, so listing sessions for a
|
||||
// cwd just means asking the serve rooted at that cwd. Returns the same shapes as the Claude reader,
|
||||
// The OpenCode analog of claude-sessions.ts. OpenCode's own store is the source of truth, read via the
|
||||
// fixed pm2-managed server's HTTP API (never the DB directly). All OpenCode /chat sessions live in that
|
||||
// one server's project, so listing is just GET /session. Returns the same shapes as the Claude reader,
|
||||
// tagged harness:'opencode', so chat.ts can merge both harnesses transparently.
|
||||
|
||||
/** List OpenCode sessions for a working directory. Never throws — returns [] if OpenCode is unavailable. */
|
||||
export async function listOpenCodeSessions(cwd: string, home: string): Promise<ClaudeSessionSummary[]> {
|
||||
/** List OpenCode sessions. Never throws — returns [] if the server is unavailable. */
|
||||
export async function listOpenCodeSessions(): Promise<ClaudeSessionSummary[]> {
|
||||
try {
|
||||
const { baseUrl } = await ensureServer(cwd, home);
|
||||
const { baseUrl } = await ensureServer();
|
||||
const sessions = await getConnection(baseUrl).listSessions();
|
||||
return sessions.map((s) => {
|
||||
const created = s.time?.created ?? Date.now();
|
||||
@@ -19,7 +19,7 @@ export async function listOpenCodeSessions(cwd: string, home: string): Promise<C
|
||||
return {
|
||||
id: s.id,
|
||||
title: s.title || '(untitled)',
|
||||
cwd,
|
||||
cwd: s.location?.directory ?? '',
|
||||
createdAt: new Date(created).toISOString(),
|
||||
updatedAt: new Date(updated).toISOString(),
|
||||
messageCount: 0, // the session list endpoint doesn't include a turn count
|
||||
@@ -27,15 +27,15 @@ export async function listOpenCodeSessions(cwd: string, home: string): Promise<C
|
||||
} satisfies ClaudeSessionSummary;
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Failed to list OpenCode sessions', { cwd, error: String(err) });
|
||||
logger.warn('Failed to list OpenCode sessions', { error: String(err) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Load one OpenCode session's transcript, rebuilt into the shared display message shape. */
|
||||
export async function loadOpenCodeSession(cwd: string, home: string, sessionId: string): Promise<ClaudeSessionDetail | null> {
|
||||
export async function loadOpenCodeSession(sessionId: string): Promise<ClaudeSessionDetail | null> {
|
||||
try {
|
||||
const { baseUrl } = await ensureServer(cwd, home);
|
||||
const { baseUrl } = await ensureServer();
|
||||
const stored = await getConnection(baseUrl).getMessages(sessionId);
|
||||
|
||||
const messages: ClaudeChatMessage[] = [];
|
||||
@@ -70,21 +70,21 @@ export async function loadOpenCodeSession(cwd: string, home: string, sessionId:
|
||||
isError: Boolean(p.state?.error),
|
||||
});
|
||||
}
|
||||
// text-part deltas already excluded reasoning; skip reasoning/step-* parts here too.
|
||||
// skip reasoning/step-* parts (parity with the streaming delta filter).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { id: sessionId, model: modelId ? `opencode/${modelId}` : 'opencode', cwd, messages };
|
||||
return { id: sessionId, model: modelId ? `opencode/${modelId}` : 'opencode', cwd: '', messages };
|
||||
} catch (err) {
|
||||
logger.warn('Failed to load OpenCode session', { sessionId, error: String(err) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteOpenCodeSession(cwd: string, home: string, sessionId: string): Promise<boolean> {
|
||||
export async function deleteOpenCodeSession(sessionId: string): Promise<boolean> {
|
||||
try {
|
||||
const { baseUrl } = await ensureServer(cwd, home);
|
||||
const { baseUrl } = await ensureServer();
|
||||
return await getConnection(baseUrl).deleteSession(sessionId);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete OpenCode session', { sessionId, error: String(err) });
|
||||
@@ -92,9 +92,9 @@ export async function deleteOpenCodeSession(cwd: string, home: string, sessionId
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameOpenCodeSession(cwd: string, home: string, sessionId: string, title: string): Promise<boolean> {
|
||||
export async function renameOpenCodeSession(sessionId: string, title: string): Promise<boolean> {
|
||||
try {
|
||||
const { baseUrl } = await ensureServer(cwd, home);
|
||||
const { baseUrl } = await ensureServer();
|
||||
return await getConnection(baseUrl).renameSession(sessionId, title);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to rename OpenCode session', { sessionId, error: String(err) });
|
||||
|
||||
@@ -105,8 +105,9 @@ class ServerConnection {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async createSession(directory: string, title?: string): Promise<string> {
|
||||
const session = await this.postJson<{ id?: string }>('/session', { directory, title });
|
||||
async createSession(title?: string): Promise<string> {
|
||||
// No `directory` — the session lives in the fixed server's own project (its cwd).
|
||||
const session = await this.postJson<{ id?: string }>('/session', title ? { title } : {});
|
||||
if (!session.id) throw new Error('opencode POST /session returned no id');
|
||||
return session.id;
|
||||
}
|
||||
|
||||
@@ -1,118 +1,19 @@
|
||||
import type { Subprocess } from 'bun';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { logger } from '../logger';
|
||||
// The OpenCode server is a fixed, pm2-managed process (see officer-opencode in ecosystem.config.cjs)
|
||||
// listening on a known port — not spawned per-cwd by us. All OpenCode chat + session traffic goes to
|
||||
// this one server; sessions live in its project store. Override the URL with OPENCODE_SERVER_URL.
|
||||
|
||||
// The `opencode` binary. Pinned (like CLAUDE_BIN) rather than resolved from PATH; override with
|
||||
// OPENCODE_BIN. NOTE: the installed binary is 1.17.9 — the 1.18.4 upgrade never landed on disk.
|
||||
const OPENCODE_BIN = process.env.OPENCODE_BIN || join(homedir(), '.opencode', 'bin', 'opencode');
|
||||
export const OPENCODE_SERVER_URL = process.env.OPENCODE_SERVER_URL || 'http://127.0.0.1:4096';
|
||||
|
||||
const HEALTH_TIMEOUT_MS = 20_000;
|
||||
const HEALTH_POLL_MS = 200;
|
||||
const START_ATTEMPTS = 3;
|
||||
|
||||
type OpenCodeServer = {
|
||||
baseUrl: string;
|
||||
proc: Subprocess;
|
||||
port: number;
|
||||
};
|
||||
|
||||
// One warm `opencode serve` per working directory (sessions bind to a directory at creation).
|
||||
const servers = new Map<string, OpenCodeServer>();
|
||||
const starting = new Map<string, Promise<OpenCodeServer>>();
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
function getFreePort(): number {
|
||||
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
const port = probe.port;
|
||||
probe.stop(true);
|
||||
if (port == null) throw new Error('failed to acquire a free port');
|
||||
return port;
|
||||
}
|
||||
|
||||
async function isHealthy(baseUrl: string): Promise<boolean> {
|
||||
export async function isServerHealthy(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(2000) });
|
||||
const res = await fetch(`${OPENCODE_SERVER_URL}/api/health`, { signal: AbortSignal.timeout(3000) });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitHealthy(baseUrl: string, timeoutMs: number): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await isHealthy(baseUrl)) return true;
|
||||
await new Promise((r) => setTimeout(r, HEALTH_POLL_MS));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function startServer(cwd: string, home: string): Promise<OpenCodeServer> {
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
|
||||
for (let attempt = 1; attempt <= START_ATTEMPTS; attempt += 1) {
|
||||
const port = getFreePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
// Loopback-only + no OPENCODE_SERVER_PASSWORD → the server is open on 127.0.0.1 (single-user box).
|
||||
// HOME is set to the caller's home so `opencode` reads that user's ~/.local/share/opencode auth.
|
||||
const proc = Bun.spawn([OPENCODE_BIN, 'serve', '--port', String(port), '--hostname', '127.0.0.1'], {
|
||||
cwd,
|
||||
env: { ...process.env, HOME: home },
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
});
|
||||
|
||||
if (await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS)) {
|
||||
logger.info('opencode serve started', { cwd, baseUrl });
|
||||
proc.exited.then((code) => {
|
||||
// Drop the cached entry on exit so the next turn respawns.
|
||||
if (servers.get(cwd)?.proc === proc) servers.delete(cwd);
|
||||
logger.warn('opencode serve exited', { cwd, code });
|
||||
});
|
||||
return { baseUrl, proc, port };
|
||||
}
|
||||
|
||||
logger.warn('opencode serve failed health check, retrying', { cwd, baseUrl, attempt });
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`opencode serve failed to start for cwd ${cwd}`);
|
||||
}
|
||||
|
||||
/** Ensure a healthy `opencode serve` for `cwd`, returning its base URL. Dedupes concurrent starts. */
|
||||
export async function ensureServer(cwd: string, home: string): Promise<{ baseUrl: string }> {
|
||||
const existing = servers.get(cwd);
|
||||
if (existing && (await isHealthy(existing.baseUrl))) {
|
||||
return { baseUrl: existing.baseUrl };
|
||||
}
|
||||
if (existing) {
|
||||
try {
|
||||
existing.proc.kill();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
servers.delete(cwd);
|
||||
}
|
||||
|
||||
const inflight = starting.get(cwd);
|
||||
if (inflight) {
|
||||
const s = await inflight;
|
||||
return { baseUrl: s.baseUrl };
|
||||
}
|
||||
|
||||
const promise = startServer(cwd, home);
|
||||
starting.set(cwd, promise);
|
||||
try {
|
||||
const server = await promise;
|
||||
servers.set(cwd, server);
|
||||
return { baseUrl: server.baseUrl };
|
||||
} finally {
|
||||
starting.delete(cwd);
|
||||
}
|
||||
/** The base URL of the fixed OpenCode server. */
|
||||
export async function ensureServer(): Promise<{ baseUrl: string }> {
|
||||
return { baseUrl: OPENCODE_SERVER_URL };
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ensureServer } from '@@/api/chat/opencode/server-manager';
|
||||
import { getConnection } from '@@/api/chat/opencode/client';
|
||||
import { createEventMapper } from '@@/api/chat/opencode/event-mapper';
|
||||
import { getOpenCodeSession, setOpenCodeSession } from '@@/api/chat/opencode/state';
|
||||
import { getHomeDirForRole } from '../data-path';
|
||||
|
||||
// The OpenCode analog of send-claude-code.ts's streaming path. Drives a turn against a warm
|
||||
// `opencode serve` over HTTP + SSE, mapping events to the shared ChatEvent contract.
|
||||
@@ -34,22 +33,19 @@ function splitModel(model: string): { providerID: string; modelID: string } {
|
||||
}
|
||||
|
||||
export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Promise<OpenCodeStreamingHandle> {
|
||||
const home = getHomeDirForRole(params.email, params.role ?? '');
|
||||
const cwd = params.cwd || home;
|
||||
|
||||
logger.info('OpenCode streaming exec', { sessionKey: params.sessionKey, model: params.model });
|
||||
|
||||
const { baseUrl } = await ensureServer(cwd, home);
|
||||
const { baseUrl } = await ensureServer();
|
||||
const conn = getConnection(baseUrl);
|
||||
|
||||
// Resolve the OpenCode session: a known mapping, or — when resuming from history — the sessionKey is
|
||||
// itself the OpenCode session id (`ses_…`); otherwise create one bound to the cwd.
|
||||
// itself the OpenCode session id (`ses_…`); otherwise create a new one.
|
||||
let opencodeSessionId =
|
||||
getOpenCodeSession(params.sessionKey) ??
|
||||
(params.sessionKey.startsWith('ses_') ? params.sessionKey : undefined) ??
|
||||
params.resumeSessionId;
|
||||
if (!opencodeSessionId) {
|
||||
opencodeSessionId = await conn.createSession(cwd);
|
||||
opencodeSessionId = await conn.createSession();
|
||||
}
|
||||
setOpenCodeSession(params.sessionKey, opencodeSessionId);
|
||||
const sessionId = opencodeSessionId;
|
||||
|
||||
Reference in New Issue
Block a user