#!/usr/bin/env node
/**
 * AgentsRoom-MCP server (stdio) — the user-facing entry name in `.mcp.json`.
 *
 * Spawned by an agent CLI (Claude Code, OpenCode, ...) through the project's
 * `.mcp.json`. Exposes the logical tool families that mirror the matching
 * panels of the AgentsRoom desktop app:
 *
 *   - backlog_*   : list, get, create, update, delete, comment backlog tasks (cloud-backed)
 *   - prompts_*   : list, get, save prompts in the project Prompt Library
 *   - skills_*    : list, get, save (upsert) skills in the Skills Library
 *   - teams_*     : list, get, save, delete agent teams (see teamsMcpTools.cjs)
 *   - agents_*    : read-only saved agent configurations (see teamsMcpTools.cjs)
 *   - memory_*    : list, get, save notes in the shared Project Memory
 *   - commands_*  : list, get, create dev commands (process / snippet)
 *   - settings_*  : list, get, set desktop app settings (see settingsMcpTools.cjs)
 *   - projects_list : the other projects of the account, addressable in reads
 *
 * Cross-project reads (server v1.9.0)
 * -----------------------------------
 * A product often spans several repositories, each opened as its own
 * AgentsRoom project with its own backlog / memory / libraries. The read tools
 * of the four cloud-backed families (backlog, memory, skills, prompts) take an
 * optional `project` argument so an agent can answer "does the backend repo
 * already have a ticket for this?" without leaving its own project. Three
 * invariants hold it together:
 *
 *   1. ADDRESSABILITY IS THE ACCOUNT, and it is gated by the server registry.
 *      `resolveProjectRef` only ever returns an id listed in
 *      `/api/project-registry` (scoped to the signed-in user) or the current
 *      `AR_PROJECT_ID`. This is not cosmetic: `/api/project-memory` is keyed by
 *      projectId ALONE with no per-user scoping, so a raw id passed straight
 *      through would read the memory of a project outside the account.
 *   2. READ-ONLY. No write tool accepts `project`, and `handleToolCall` refuses
 *      the argument on every non-read tool instead of ignoring it — silently
 *      dropping it would write to the CURRENT project, which is precisely the
 *      orphan write this feature must never produce.
 *   3. NO LOCAL SIDE EFFECT. The `.agentsroom/*-cache.json` mirrors and the
 *      `.agentsroom/memory/**.md` materialization belong to AR_PROJECT_PATH, so
 *      a cross-project read never refreshes them (it would overwrite this
 *      project's mirror with another project's data).
 *
 * Why a unified server (vs. three separate stdio entries)
 * -------------------------------------------------------
 * One subprocess per MCP entry adds memory + fork cost for what is in
 * practice the same auth flow and the same project context. A single server
 * with three tool namespaces gives the agent the same surface area without
 * the per-entry overhead.
 *
 * Communication model
 * -------------------
 *   - Reads & writes for backlog hit the cloud API directly (HTTPS) using the
 *     desktop user's auth token, read live from
 *     `~/.agentsroom/<settingsFile>.json`. We re-read on every call so a token
 *     refresh in the desktop app propagates to ongoing MCP sessions.
 *   - Reads for prompts / commands hit the same cloud API.
 *   - Writes for prompts / commands also push to the cloud API and refresh
 *     the local cache file `<workspace>/.agentsroom/<file>-cache.json` so the
 *     desktop UI sees the change on its next focus / reload.
 *
 * Why not the WebSocket bridge
 * ----------------------------
 * The browser MCP needs the bridge because the embedded BrowserView only
 * exists inside the Electron main process. Backlog / prompts / commands are
 * persisted server-side, so going straight to HTTPS is simpler and works
 * even when the user closed the desktop app between two agent turns.
 *
 * Required env vars
 * -----------------
 *   AR_PROJECT_ID    : repoProjectId (the stable id committed to git, NOT the
 *                      local sidebar id). Used as the projectId for every
 *                      cloud API call.
 *   AR_PROJECT_PATH  : absolute path of the project workspace (project root
 *                      or worktree). Used for cache file refresh.
 *   AR_API_BASE_URL  : cloud API base URL (e.g. https://agentsroom.dev).
 *   AR_SETTINGS_FILE : absolute path to the user's settings file
 *                      (e.g. ~/.agentsroom/settings.json) — re-read on every
 *                      call so a refreshed auth token propagates.
 *
 * Keep this file dependency-free: it ships as-is inside the packaged
 * Electron app and gets executed by `node /path/to/app-mcp-server.cjs`.
 */

'use strict';

const fs = require('node:fs');
const path = require('node:path');
const readline = require('node:readline');
const https = require('node:https');
const http = require('node:http');
const net = require('node:net');
const crypto = require('node:crypto');
const { URL } = require('node:url');
// Sibling modules: they live next to this file in dev (`electron/`) and
// packaged (flat in `resourcesPath`), so the relative requires resolve in both.
const { materializeMemory } = require('./memoryMaterializer.cjs');
const { TEAM_TOOLS, createTeamTools } = require('./teamsMcpTools.cjs');
const { SETTINGS_TOOLS, createSettingsTools } = require('./settingsMcpTools.cjs');

const SERVER_VERSION = '1.10.0';

// ── Env resolution + hard fail ────────────────────────────────────────────

const PROJECT_ID = process.env.AR_PROJECT_ID;
const PROJECT_PATH = process.env.AR_PROJECT_PATH;
const API_BASE_URL = process.env.AR_API_BASE_URL || 'https://agentsroom.dev';
const SETTINGS_FILE = process.env.AR_SETTINGS_FILE;
// Optional: when present, enables `commands_run` (which talks to the local
// desktop bridge to actually launch a saved command in a terminal tab).
const BRIDGE_FILE = process.env.AR_BRIDGE_FILE;

if (!PROJECT_ID || !PROJECT_PATH || !SETTINGS_FILE) {
  // eslint-disable-next-line no-console
  console.error(
    '[AgentsRoom-MCP] missing env: need AR_PROJECT_ID, AR_PROJECT_PATH, AR_SETTINGS_FILE',
  );
  process.exit(1);
}

const AGENTSROOM_DIR = path.join(PROJECT_PATH, '.agentsroom');
const PROMPTS_CACHE_FILE = path.join(AGENTSROOM_DIR, 'prompts-cache.json');
const COMMANDS_CACHE_FILE = path.join(AGENTSROOM_DIR, 'commands-cache.json');
const SKILLS_CACHE_FILE = path.join(AGENTSROOM_DIR, 'skills-cache.json');
const MEMORY_CACHE_FILE = path.join(AGENTSROOM_DIR, 'memory-cache.json');
const TEAMS_CACHE_FILE = path.join(AGENTSROOM_DIR, 'teams-cache.json');

// Canonical role ids → portable bundled role avatar in the UI. Unknown values
// are dropped (the renderer only maps the built-in role set). Shared by the
// memory_save / skills_save / prompts_save attribution.
const KNOWN_AGENT_ROLES = ['devops', 'fullstack', 'frontend', 'backend', 'architect', 'qa', 'marketing', 'pm', 'security', 'mobile', 'git', 'seo', 'i18n', 'brainstormer', 'custom'];

// Attribution: the MCP server is shared by every agent of the project and
// does not know its caller, so the agent self-declares via `author` / `role`.
// The role string is portable (agent ids are machine-local and never stored).
function buildAgentEditedBy(args) {
  const author = typeof args.author === 'string' ? args.author.trim() : '';
  const authorRole = typeof args.role === 'string' && KNOWN_AGENT_ROLES.includes(args.role.trim())
    ? args.role.trim()
    : undefined;
  return {
    kind: 'agent',
    name: author || 'Agent',
    agentRole: author || undefined,
    ...(authorRole ? { role: authorRole } : {}),
  };
}

// Contributor history cap. Mirrors MAX_CONTRIBUTORS in
// src/components/skill-library/attribution.ts — keep both in sync.
const MAX_CONTRIBUTORS = 8;

// Stable dedup identity of an editor: users by server account id (name
// fallback), agents by canonical role (name fallback for custom agents).
function contributorIdentity(ref) {
  return ref.kind === 'user'
    ? `user:${ref.userId || ref.name}`
    : `agent:${ref.role || ref.name}`;
}

// Mirror of buildContributors() in src/components/skill-library/attribution.ts:
// append the editor to the dedupped history (an existing entry of the same
// identity is removed, the editor re-lands LAST with the new date), seeding
// from a legacy editedBy-only entry, capped at MAX_CONTRIBUTORS.
function buildContributors(previous, editedBy, at) {
  const seed = previous && Array.isArray(previous.contributors) && previous.contributors.length > 0
    ? previous.contributors
    : previous && previous.editedBy
      ? [{ ...previous.editedBy, at: previous.updatedAt || at }]
      : [];
  const identity = contributorIdentity(editedBy);
  const kept = seed.filter((c) => c && contributorIdentity(c) !== identity);
  return [...kept, { ...editedBy, at }].slice(-MAX_CONTRIBUTORS);
}

// ── Auth token (re-read on every call) ────────────────────────────────────

function readAuthToken() {
  try {
    const raw = fs.readFileSync(SETTINGS_FILE, 'utf-8');
    const parsed = JSON.parse(raw);
    if (parsed && typeof parsed === 'object' && typeof parsed.authToken === 'string') {
      return parsed.authToken;
    }
  } catch (_) { /* missing or unreadable */ }
  return null;
}

// ── Tiny HTTPS client ─────────────────────────────────────────────────────

function httpRequest(method, urlStr, body) {
  return new Promise((resolve, reject) => {
    let parsed;
    try { parsed = new URL(urlStr); }
    catch (err) { reject(err); return; }

    const lib = parsed.protocol === 'https:' ? https : http;
    const headers = { Accept: 'application/json' };
    const token = readAuthToken();
    if (token) headers.Authorization = `Bearer ${token}`;
    let payload = null;
    if (body !== undefined && body !== null) {
      payload = Buffer.from(JSON.stringify(body), 'utf-8');
      headers['Content-Type'] = 'application/json';
      headers['Content-Length'] = String(payload.length);
    }

    const req = lib.request({
      method,
      hostname: parsed.hostname,
      port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
      path: parsed.pathname + (parsed.search || ''),
      headers,
    }, (res) => {
      const chunks = [];
      res.on('data', (c) => chunks.push(c));
      res.on('end', () => {
        const raw = Buffer.concat(chunks).toString('utf-8');
        const status = res.statusCode || 0;
        let data = null;
        if (raw) {
          try { data = JSON.parse(raw); }
          catch (_) { data = raw; }
        }
        if (status >= 200 && status < 300) {
          resolve({ status, data });
        } else {
          const msg = data && typeof data === 'object' && data.error
            ? String(data.error)
            : `HTTP ${status}`;
          reject(new Error(msg));
        }
      });
    });
    req.on('error', reject);
    req.setTimeout(30_000, () => {
      req.destroy(new Error('http request timeout after 30s'));
    });
    if (payload) req.write(payload);
    req.end();
  });
}

function apiGet(path) {
  return httpRequest('GET', `${API_BASE_URL}${path}`).then((r) => r.data);
}

function apiPost(path, body) {
  return httpRequest('POST', `${API_BASE_URL}${path}`, body).then((r) => r.data);
}

function apiPatch(path, body) {
  return httpRequest('PATCH', `${API_BASE_URL}${path}`, body).then((r) => r.data);
}

function apiDelete(path) {
  return httpRequest('DELETE', `${API_BASE_URL}${path}`).then((r) => r.data);
}

// ── Cross-project addressing ──────────────────────────────────────────────
//
// The account project registry (`/api/project-registry`, scoped server-side to
// the signed-in user) is the ONLY source of addressable project ids. It is the
// security boundary of this feature, not a convenience: `/api/project-memory`
// is keyed by projectId alone with no per-user scoping, so an unchecked id
// would let an agent read the memory of a project it has no relation to.
//
// Cached like the renderer's cross-project library search (60 s): an agent
// resolving a name on every tool call must not mean an HTTP round-trip each
// time. Failures are cached far shorter so signing in mid-session recovers
// quickly instead of waiting out the full window.

const PROJECT_REGISTRY_TTL_MS = 60_000;
const PROJECT_REGISTRY_FAIL_TTL_MS = 10_000;

let registryCache = { at: 0, ttl: 0, projects: null };

/**
 * Every project of the account, as `{ id, name }`. `null` when the registry is
 * unreachable (offline, signed out) — callers must degrade, never assume empty.
 */
async function fetchAccountProjects() {
  const now = Date.now();
  if (registryCache.at && now - registryCache.at < registryCache.ttl) {
    return registryCache.projects;
  }
  try {
    const data = await apiGet('/api/project-registry');
    const rows = Array.isArray(data && data.projects) ? data.projects : [];
    const projects = rows
      .filter((p) => p && typeof p.projectId === 'string' && p.projectId.trim())
      .map((p) => ({
        id: p.projectId,
        name: typeof p.name === 'string' && p.name.trim() ? p.name.trim() : p.projectId,
      }));
    registryCache = { at: now, ttl: PROJECT_REGISTRY_TTL_MS, projects };
    return projects;
  } catch (_) {
    registryCache = { at: now, ttl: PROJECT_REGISTRY_FAIL_TTL_MS, projects: null };
    return null;
  }
}

// Unicode combining marks, built from escapes so this file stays pure ASCII.
const COMBINING_MARKS = new RegExp('[' + String.fromCharCode(0x300) + '-' + String.fromCharCode(0x36f) + ']', 'g');

/** Accent- and punctuation-insensitive key so "AI Layers Backend", "ai-layers-backend"
 *  and "ai_layers_backend" all designate the same project. */
function normalizeProjectKey(value) {
  return String(value == null ? '' : value)
    .normalize('NFD')
    .replace(COMBINING_MARKS, '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '');
}

const PROJECT_REF_HINT =
  'Call `projects_list` to see the projects you can address, then pass the exact `name` or `id` it returns.';

/**
 * Resolve the optional `project` argument to a target project.
 * Returns `{ id, name, isCurrent }`; throws a message written for the agent
 * when the reference is unknown or ambiguous. It NEVER guesses: two projects
 * matching one name is an error, because reading the wrong backlog silently is
 * worse than answering "be more specific".
 */
async function resolveProjectRef(ref) {
  const raw = typeof ref === 'string' ? ref.trim() : '';
  if (!raw || raw === PROJECT_ID) return { id: PROJECT_ID, name: null, isCurrent: true };

  const projects = await fetchAccountProjects();
  if (!projects) {
    throw new Error(
      `Cannot resolve project "${raw}": the account project registry is unreachable (offline, or not signed in to AgentsRoom). Cross-project reads need it. The current project still works: just omit \`project\`.`,
    );
  }

  const byId = projects.find((p) => p.id === raw);
  if (byId) return { id: byId.id, name: byId.name, isCurrent: byId.id === PROJECT_ID };

  const key = normalizeProjectKey(raw);
  const byName = key ? projects.filter((p) => normalizeProjectKey(p.name) === key) : [];
  if (byName.length === 1) {
    return { id: byName[0].id, name: byName[0].name, isCurrent: byName[0].id === PROJECT_ID };
  }
  if (byName.length > 1) {
    const candidates = byName.map((p) => `${p.name} [${p.id}]`).join(', ');
    throw new Error(
      `"${raw}" matches ${byName.length} projects of this account (${candidates}). Pass the id instead — guessing would read the wrong project.`,
    );
  }
  throw new Error(`Unknown project "${raw}" on this account. ${PROJECT_REF_HINT}`);
}

/**
 * Stamp a cross-project payload with its origin so the agent can never confuse
 * it with the current project, and restate that it cannot write there. The
 * current project keeps its historical (unstamped) payload shape.
 */
function withProjectStamp(target, payload) {
  if (target.isCurrent) return payload;
  return {
    project: { id: target.id, name: target.name, current: false },
    scope:
      'Read from another project of your account. You cannot write there over MCP: file the ticket or the note in the project it belongs to, or ask the user.',
    ...payload,
  };
}

/** Read tools that accept `project`. Anything else is refused (see handleToolCall). */
const CROSS_PROJECT_READ_TOOLS = new Set([
  'backlog_list',
  'backlog_get',
  'memory_list',
  'memory_get',
  'skills_list',
  'skills_get',
  'prompts_list',
  'prompts_get',
]);

// ── Cache file refresh ────────────────────────────────────────────────────
//
// After a write to prompts / commands hits the cloud, refresh the local cache
// file so the desktop UI picks up the change on its next focus event without
// needing a full project reload. Best-effort: if the cache file can't be
// written, the UI will still catch up on its next sync poll.

function atomicWriteJson(destPath, obj) {
  try {
    fs.mkdirSync(path.dirname(destPath), { recursive: true });
    const tmp = `${destPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
    fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), 'utf-8');
    fs.renameSync(tmp, destPath);
  } catch (_) { /* best-effort */ }
}

function refreshPromptsCache(folders, prompts, updatedAt) {
  atomicWriteJson(PROMPTS_CACHE_FILE, {
    data: { folders, prompts },
    updatedAt: updatedAt || new Date().toISOString(),
    dirty: false,
    projectId: PROJECT_ID,
  });
}

function refreshCommandsCache(folders, commands, updatedAt) {
  atomicWriteJson(COMMANDS_CACHE_FILE, {
    data: { folders, commands },
    updatedAt: updatedAt || new Date().toISOString(),
    dirty: false,
    projectId: PROJECT_ID,
  });
}

// Account-level commands mirror (`~/.agentsroom/global-commands.json`), the
// same file the renderer store and the Electron main process read. Derived
// from AR_SETTINGS_FILE so no extra machine path has to be injected into the
// project's `.mcp.json`.
function refreshGlobalCommandsCache(folders, commands) {
  try {
    const userDir = path.dirname(SETTINGS_FILE);
    atomicWriteJson(path.join(userDir, 'global-commands.json'), { folders, commands });
  } catch (_) { /* best-effort */ }
}

function refreshSkillsCache(folders, skills, updatedAt) {
  atomicWriteJson(SKILLS_CACHE_FILE, {
    data: { folders, skills },
    updatedAt: updatedAt || new Date().toISOString(),
    dirty: false,
    projectId: PROJECT_ID,
  });
}

// Teams cache mirrors projectSync's TeamsCache shape ({ data: Team[] }). The
// desktop reloads teams from the server whenever the Teams panel / picker
// opens, so this refresh only matters for offline reads, exactly like the
// other caches.
function refreshTeamsCache(teams, updatedAt) {
  atomicWriteJson(TEAMS_CACHE_FILE, {
    data: teams,
    updatedAt: updatedAt || new Date().toISOString(),
    dirty: false,
    projectId: PROJECT_ID,
  });
}

// Memory cache is read by the desktop in the STORE shape ({ folders, skills }),
// while the API wire format is { folders, notes }. Convert here.
//
// Also re-materializes the markdown mirror under `.agentsroom/memory/`. That
// keeps read-your-own-writes true for the calling agent (it saves a note, then
// reads it back from disk), and because `memory_save` re-fetches the whole
// shared row before pushing, ANY agent write also pulls in whatever teammates
// wrote since the last sync: every write is a freshness checkpoint.
function refreshMemoryCache(folders, notes, updatedAt) {
  const revision = updatedAt || new Date().toISOString();
  atomicWriteJson(MEMORY_CACHE_FILE, {
    data: { folders, skills: notes },
    updatedAt: revision,
    dirty: false,
    projectId: PROJECT_ID,
  });
  try {
    materializeMemory({
      projectPath: PROJECT_PATH,
      folders,
      notes,
      updatedAt: revision,
      projectId: PROJECT_ID,
    });
  } catch (_) { /* mirror is an optimization; the MCP tools stay authoritative */ }
}

// ── Teams + saved-agents tools (implemented in teamsMcpTools.cjs) ─────────

const teamTools = createTeamTools({
  projectId: PROJECT_ID,
  apiGet,
  apiPost,
  refreshTeamsCache,
  knownAgentRoles: KNOWN_AGENT_ROLES,
});

// ── Local desktop bridge (WS) — used only by `commands_run` ───────────────
//
// The bridge exposes ops the cloud API can't serve: spawning a real PTY
// inside the Electron main process so the user gets a proper terminal tab
// in the Dev Commands panel. We re-read the bridge descriptor on every
// connection attempt because the desktop app rotates `port` and `token` at
// every boot.
//
// Liveness check
// --------------
// Electron's `app.relaunch()` + `app.exit()` (used by the auto-updater) can
// bypass the `before-quit` cleanup, leaving the descriptor file pointing at a
// dead pid + dead port while the next instance binds something else. Without
// the pid liveness check below, every call would surface as a confusing
// ECONNREFUSED on a port the running desktop never opened. We detect the
// stale pid up-front, then give the live desktop a few seconds to reclaim the
// descriptor (see `resolveBridgeConfig`) before failing with a clear
// "restart AgentsRoom" message.

function readBridgeConfig() {
  if (!BRIDGE_FILE) return null;
  try {
    const raw = fs.readFileSync(BRIDGE_FILE, 'utf-8');
    const parsed = JSON.parse(raw);
    if (typeof parsed.port !== 'number' || typeof parsed.token !== 'string') return null;
    if (typeof parsed.pid === 'number' && parsed.pid > 0) {
      try {
        process.kill(parsed.pid, 0);
      } catch (err) {
        // ESRCH = no such process → file is stale, the writing desktop is gone.
        // EPERM = process exists but we can't signal it (different uid) → treat
        // as alive: the process is there, the bridge is presumably running.
        if (err && err.code === 'ESRCH') return null;
      }
    }
    return { wsUrl: `ws://127.0.0.1:${parsed.port}/browser-mcp`, token: parsed.token };
  } catch (_) { return null; }
}

class SimpleWS {
  constructor(url) {
    const u = new URL(url);
    this.url = u;
    this.socket = null;
    this.connected = false;
    this.handlers = new Map();
    this.buffer = Buffer.alloc(0);
    this._nextId = 1;
  }
  connect() {
    return new Promise((resolve, reject) => {
      const key = crypto.randomBytes(16).toString('base64');
      const host = this.url.hostname;
      const port = this.url.port || 80;
      const wsPath = this.url.pathname + (this.url.search || '');
      this.socket = net.connect(Number(port), host, () => {
        this.socket.write(
          `GET ${wsPath} HTTP/1.1\r\nHost: ${host}:${port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`,
        );
      });
      let handshakeParsed = false;
      this.socket.on('data', (chunk) => {
        this.buffer = Buffer.concat([this.buffer, chunk]);
        if (!handshakeParsed) {
          const sep = this.buffer.indexOf('\r\n\r\n');
          if (sep === -1) return;
          const headers = this.buffer.slice(0, sep).toString('utf-8');
          this.buffer = this.buffer.slice(sep + 4);
          if (!/HTTP\/1\.1 101/i.test(headers)) {
            reject(new Error('websocket handshake failed: ' + headers.split('\r\n')[0]));
            return;
          }
          handshakeParsed = true;
          this.connected = true;
          resolve();
        }
        if (handshakeParsed) this._consumeFrames();
      });
      this.socket.on('error', (err) => {
        this.connected = false;
        if (!handshakeParsed) reject(err);
      });
      this.socket.on('close', () => {
        this.connected = false;
        for (const { reject: rj } of this.handlers.values()) rj(new Error('websocket closed'));
        this.handlers.clear();
      });
    });
  }
  _consumeFrames() {
    while (this.buffer.length >= 2) {
      const fin = (this.buffer[0] & 0x80) !== 0;
      const opcode = this.buffer[0] & 0x0f;
      let payloadLen = this.buffer[1] & 0x7f;
      let offset = 2;
      if (payloadLen === 126) {
        if (this.buffer.length < 4) return;
        payloadLen = this.buffer.readUInt16BE(2); offset = 4;
      } else if (payloadLen === 127) {
        if (this.buffer.length < 10) return;
        payloadLen = Number(this.buffer.readBigUInt64BE(2)); offset = 10;
      }
      if (this.buffer.length < offset + payloadLen) return;
      const payload = this.buffer.slice(offset, offset + payloadLen);
      this.buffer = this.buffer.slice(offset + payloadLen);
      if (opcode === 0x8) { try { this.socket.end(); } catch (_) {} return; }
      if (opcode === 0x9) { this._sendFrame(0xa, payload); continue; }
      if (opcode === 0x1 && fin) {
        const text = payload.toString('utf-8');
        let parsed = null;
        try { parsed = JSON.parse(text); } catch (_) { /* ignore */ }
        if (parsed && parsed.id && this.handlers.has(parsed.id)) {
          const { resolve } = this.handlers.get(parsed.id);
          this.handlers.delete(parsed.id);
          resolve(parsed);
        }
      }
    }
  }
  _sendFrame(opcode, payload) {
    const mask = crypto.randomBytes(4);
    const len = payload.length;
    const header = [0x80 | opcode];
    if (len < 126) header.push(0x80 | len);
    else if (len < 65536) header.push(0x80 | 126, (len >> 8) & 0xff, len & 0xff);
    else {
      const hi = Math.floor(len / 0x100000000);
      const lo = len >>> 0;
      header.push(0x80 | 127,
        (hi >> 24) & 0xff, (hi >> 16) & 0xff, (hi >> 8) & 0xff, hi & 0xff,
        (lo >> 24) & 0xff, (lo >> 16) & 0xff, (lo >> 8) & 0xff, lo & 0xff);
    }
    const masked = Buffer.alloc(len);
    for (let i = 0; i < len; i++) masked[i] = payload[i] ^ mask[i % 4];
    this.socket.write(Buffer.concat([Buffer.from(header), mask, masked]));
  }
  send(obj, timeoutMs = 30_000) {
    return new Promise((resolve, reject) => {
      if (!this.connected) { reject(new Error('websocket not connected')); return; }
      const id = String(this._nextId++);
      const timer = setTimeout(() => {
        if (this.handlers.has(id)) {
          this.handlers.delete(id);
          reject(new Error('bridge rpc timeout after ' + timeoutMs + 'ms'));
        }
      }, timeoutMs);
      const settle = (fn) => (value) => { clearTimeout(timer); fn(value); };
      this.handlers.set(id, { resolve: settle(resolve), reject: settle(reject) });
      const payload = Buffer.from(JSON.stringify({ ...obj, id }), 'utf-8');
      try { this._sendFrame(0x1, payload); } catch (err) { clearTimeout(timer); reject(err); }
    });
  }
  close() { try { this.socket && this.socket.end(); } catch (_) {} }
}

let cachedWs = null;
let cachedToken = null;

// Stale-descriptor tolerance
// --------------------------
// A descriptor pointing at a dead pid used to be a PERMANENT failure: the file
// was only (re)written at desktop boot, so once a short-lived sibling instance
// published its own dying port, every bridge tool in every project answered
// "desktop bridge unavailable" until the user restarted the app. The desktop
// now re-asserts the descriptor every few seconds (see
// `electron/utils/discoveryFile.ts`), which makes a stale read transient — so
// we wait out one reconcile window before giving up.
const BRIDGE_RESOLVE_ATTEMPTS = 5;
const BRIDGE_RESOLVE_DELAY_MS = 1500;

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function resolveBridgeConfig() {
  for (let attempt = 1; attempt <= BRIDGE_RESOLVE_ATTEMPTS; attempt += 1) {
    const cfg = readBridgeConfig();
    if (cfg) return cfg;
    if (attempt < BRIDGE_RESOLVE_ATTEMPTS) await delay(BRIDGE_RESOLVE_DELAY_MS);
  }
  return null;
}

// `timeoutMs` overrides the default RPC deadline. Only ops the desktop answers
// after real work need it (`agent:spawn` may check out a git worktree first);
// everything else is a store read and answers in milliseconds.
async function bridgeCall(op, params, timeoutMs) {
  const cfg = await resolveBridgeConfig();
  if (!cfg) throw new Error('desktop bridge unavailable: restart AgentsRoom');
  const attempt = async (config) => {
    if (!cachedWs || !cachedWs.connected || cachedToken !== config.token) {
      if (cachedWs) { try { cachedWs.close(); } catch (_) {} }
      cachedWs = new SimpleWS(config.wsUrl);
      cachedToken = config.token;
      await cachedWs.connect();
    }
    const resp = await cachedWs.send(
      { op, projectId: PROJECT_ID, params, token: cachedToken },
      ...(timeoutMs ? [timeoutMs] : []),
    );
    if (!resp || resp.ok === false) throw new Error((resp && resp.error) || 'bridge error');
    return resp.result;
  };
  try { return await attempt(cfg); }
  catch (_) {
    // Desktop may have restarted (or reclaimed the descriptor) between calls —
    // drop the cached socket and retry with a freshly-read descriptor. Re-read
    // it for real: retrying on the same dead port could only fail again.
    if (cachedWs) { try { cachedWs.close(); } catch (_) {} }
    cachedWs = null;
    cachedToken = null;
    const fresh = (await resolveBridgeConfig()) || cfg;
    return attempt(fresh);
  }
}

// ── App settings tools (implemented in settingsMcpTools.cjs) ──────────────
//
// Reads go straight to the settings file (they work with the app closed);
// writes go through the bridge so the open windows are updated instead of
// silently reverting the file on their next save.

const settingsTools = createSettingsTools({
  settingsFile: SETTINGS_FILE,
  bridgeCall,
  bridgeAvailable: !!BRIDGE_FILE,
});

// ── Tool schemas ──────────────────────────────────────────────────────────

/**
 * Execution isolation, shared by backlog_create and backlog_update. Mirrors the
 * "Code in a separate worktree for this task" checkbox of the desktop task
 * editor: same stored fields, same effect at spawn time.
 */
const WORKTREE_PROPERTIES = {
  useWorktree: {
    type: 'boolean',
    description:
      'Run this task in its own git worktree (default false). true: when the task moves to "in_progress" the agent is spawned in a dedicated checkout at `.worktrees/<worktreeBranch>/`, on its own branch, so parallel tasks cannot conflict in the shared working tree. false: the agent runs in the project\'s shared checkout. This is the same setting as the "Code in a separate worktree for this task" checkbox in the task editor. Setting it to true is enough — the branch name is derived from the title when `worktreeBranch` is omitted. Omit the field entirely to leave an existing task\'s setting untouched.',
  },
  worktreeBranch: {
    type: 'string',
    description:
      'Branch checked out in the dedicated worktree. Only meaningful with `useWorktree: true`. Defaults to a slug of the task title (e.g. "Fix login redirect" gives "fix-login-redirect"). Pass a value only when you need a specific branch name.',
  },
  autoMergeWorktree: {
    type: 'boolean',
    description:
      'Default false. When true, marking the task done merges its worktree branch back into the branch currently checked out at the project root (`git merge <branch> --no-edit`). Leave false to review and merge the branch yourself.',
  },
};

/**
 * Per-ticket executor overrides, shared by backlog_create and backlog_update.
 * `agentId` picks WHO runs the ticket; these pick HOW, for this ticket only.
 *
 * Resolution order at spawn time, field by field:
 *   ticket override -> agent configuration -> project default -> system default
 *
 * Every field is optional and means "inherit from the executor" when omitted.
 * Pass an empty string to CLEAR an override that was set before.
 */
const EXECUTOR_PROPERTIES = {
  provider: {
    type: 'string',
    description:
      'CLI provider that runs THIS ticket, overriding the executor\'s own ("claude", "codex", "opencode", ...). Omit to inherit from the assigned agent (the normal case, and the one that keeps following the agent when its configuration changes). Use it for a one-off requirement, e.g. running one ticket on Codex while the agent stays on Claude. Empty string clears the override. Call agents_list to see which providers the project actually uses.',
  },
  model: {
    type: 'string',
    description:
      'Model this ticket runs on, overriding the executor\'s own. Must belong to the EFFECTIVE provider (the one above, else the agent\'s): a mismatched id is dropped at launch and the executor model is used instead, so set `provider` too when you switch CLI. Reasoning-tier models are exposed as regular ids (e.g. "gpt-5.6-sol-max" = Sol at max reasoning). Omit to inherit; empty string clears.',
  },
  reasoningEffort: {
    type: 'string',
    enum: ['low', 'medium', 'high', 'xhigh', 'max'],
    description:
      'How hard the model thinks on this ticket, for providers that expose the lever (Codex maps it to `model_reasoning_effort`; it wins over the effort baked into a model alias). Ignored, with a warning, on a provider without such a setting. Omit to leave the model on its default; empty string clears.',
  },
  accountProfileId: {
    type: 'string',
    description:
      'Account profile (logged-in CLI account) this ticket runs under, as reported by agents_list. MACHINE-LOCAL: a profile id only exists on the machine that created it, so on any other machine the ticket silently falls back to its executor account. Prefer leaving it inherited unless the user explicitly asked for a specific account. Empty string clears.',
  },
  skillIds: {
    type: 'array',
    items: { type: 'string' },
    description:
      'Skills attached to this ticket, injected into the agent\'s first message when it starts. Ids come from skills_list. Combined with the executor\'s own always-on skills according to `skillsMode`. Passing an empty array detaches every skill.',
  },
  skillsMode: {
    type: 'string',
    enum: ['merge', 'replace'],
    description:
      'How `skillIds` combines with the executor\'s default skills. "merge" (default) layers the ticket skills on top of the agent\'s always-on toolkit. "replace" runs the ticket with ONLY the skills listed here, dropping the agent\'s defaults for this run.',
  },
};

/**
 * The optional cross-project selector, shared by every READ tool of the
 * cloud-backed families. Deliberately absent from every write tool: see the
 * "Cross-project reads" note at the top of this file.
 */
const PROJECT_REF_PROPERTIES = {
  project: {
    type: 'string',
    description:
      'Read a DIFFERENT project of the same AgentsRoom account instead of this one: its name or its id, exactly as `projects_list` returns them. Omit it for the current project, which is what you want almost every time. Reach for it when the work genuinely spans several repositories of one product: checking whether the sibling repo already has a ticket for the change you are about to file, or reading a decision recorded in its memory. Read-only: no tool lets you write into, or run anything in, another project.',
  },
};

const TOOLS = [
  // ── Projects (cross-project addressing) ──
  {
    name: 'projects_list',
    description:
      'List every project of this AgentsRoom account, so you can then read one of them with the `project` argument of backlog_list / backlog_get, memory_list / memory_get, skills_list / skills_get and prompts_list / prompts_get. Call it when the product spans several repositories and the task points outside this one: before filing a ticket that may already exist in the backend repo, before re-implementing something a sibling project may already have shipped, or to follow a decision recorded in another project\'s memory. Returns each project with its id, its name, and whether it is the current one. Purely informational: nothing here writes anywhere, and running commands or agents in another project is not supported.',
    inputSchema: { type: 'object', properties: {} },
  },

  // ── Backlog ──
  {
    name: 'backlog_list',
    description:
      'List backlog tasks for the current project. Use this when the user mentions "the backlog", asks "what is on the roadmap", or you need to check whether a feature is already planned before duplicating work. Returns id, title, status, ticketType, tags and the execution settings (agentId or teamId, useWorktree, worktreeBranch) for each task. Filter with `status` ("todo" | "in_progress" | "pending" | "done") to narrow down. Pass `project` to read a sibling repository\'s backlog instead (see `projects_list`).',
    inputSchema: {
      type: 'object',
      properties: {
        status: {
          type: 'string',
          enum: ['todo', 'in_progress', 'pending', 'done'],
          description: 'Filter by status.',
        },
        limit: {
          type: 'number',
          description: 'Max number of tasks to return (default 50).',
        },
        ...PROJECT_REF_PROPERTIES,
      },
    },
  },
  {
    name: 'backlog_get',
    description:
      'Return the full content of a single backlog task (description, tags, linked tasks, client context) plus how it will run. The response separates `executorOverrides` (what the ticket itself pins: provider, model, reasoningEffort, accountProfileId, skillIds, skillsMode) from `effectiveExecutor` (what it will ACTUALLY run with once the assigned agent\'s configuration is applied, with the source of each value) and `validation` (overrides that cannot apply, e.g. a model that belongs to another provider or a skill that no longer exists). Call this when you have a taskId and need the full body before working on it, or to verify how a task is configured to run.',
    inputSchema: {
      type: 'object',
      properties: { taskId: { type: 'string' }, ...PROJECT_REF_PROPERTIES },
      required: ['taskId'],
    },
  },
  {
    name: 'backlog_create',
    description:
      'Create a new backlog task. ASK THE USER FIRST before calling this tool — the backlog is theirs to curate, do not auto-log every passing remark. Use it only after explicit confirmation (or when the user clearly asked you to log something). Keep `title` short (under 80 chars); put details in `description`. Do NOT use it for the task you are currently working on.',
    inputSchema: {
      type: 'object',
      properties: {
        title: { type: 'string' },
        description: { type: 'string' },
        ticketType: {
          type: 'string',
          enum: ['feature', 'bug', 'refactor', 'chore', 'docs', 'style', 'perf', 'test', 'security'],
        },
        tags: { type: 'array', items: { type: 'string' } },
        status: {
          type: 'string',
          enum: ['todo', 'in_progress', 'pending', 'done'],
          description: 'Default "todo".',
        },
        agentId: {
          type: 'string',
          description:
            'Execution agent that will run this task when it moves to "in_progress". Prefer a portable generic role id (stable across machines): "role:fullstack", "role:frontend", "role:backend", "role:qa", "role:devops", "role:architect", "role:security", "role:mobile", "role:pm", "role:marketing", "role:seo", "role:git", "role:brainstormer". A specific project agent id (as returned by backlog_list) also works but is machine-local. Omit to leave unassigned (the task falls back to the project default agent). Mutually exclusive with `teamId`.',
        },
        teamId: {
          type: 'string',
          description:
            'Agent TEAM (a multi-agent pipeline such as Dev -> QA -> Reviewer) that runs this task when it moves to "in_progress", instead of a single agent. Ids come from teams_list / teams_get. Mutually exclusive with `agentId`: assigning a team clears any agent assignment (a team wins if both are passed). Omit to leave unassigned; empty string clears the team.',
        },
        author: { type: 'string', description: 'Your agent display name and role, e.g. "Full-Stack Developer". Always pass it so the board shows who filed the ticket.' },
        role: { type: 'string', description: 'Your canonical role id, one of: fullstack, frontend, backend, architect, qa, devops, security, mobile, pm, marketing, git, seo, i18n, brainstormer, custom (the filename of your role file). Enables your role avatar next to the ticket.' },
        ...WORKTREE_PROPERTIES,
        ...EXECUTOR_PROPERTIES,
      },
      required: ['title'],
    },
  },
  {
    name: 'backlog_update',
    description:
      'Update an existing backlog task (status, title, description, tags, ticketType, agentId, teamId, position, worktree settings, executor overrides). Assign the execution agent PER TASK with `agentId` (each task keeps its own agent; updating one never touches the others), and pin HOW it runs with the executor overrides (provider, model, reasoningEffort, accountProfileId, skillIds, skillsMode) when a ticket has a one-off requirement its agent does not cover. ASK THE USER FIRST before calling this tool unless they explicitly said "mark X done", "move Y to in_progress", "assign task Z to the frontend agent", "bump Z to the top", etc. Only the fields you pass are updated; everything else is preserved. Never bulk-update. To reorder MULTIPLE tasks at once, use `backlog_reorder` instead: it sets contiguous positions in a single transaction.',
    inputSchema: {
      type: 'object',
      properties: {
        taskId: { type: 'string' },
        title: { type: 'string' },
        description: { type: 'string' },
        status: { type: 'string', enum: ['todo', 'in_progress', 'pending', 'done'] },
        ticketType: {
          type: 'string',
          enum: ['feature', 'bug', 'refactor', 'chore', 'docs', 'style', 'perf', 'test', 'security'],
        },
        tags: { type: 'array', items: { type: 'string' } },
        agentId: {
          type: 'string',
          description:
            'Execution agent for THIS task only, applied when it moves to "in_progress". Prefer a portable generic role id (stable across machines): "role:fullstack", "role:frontend", "role:backend", "role:qa", "role:devops", "role:architect", "role:security", "role:mobile", "role:pm", "role:marketing", "role:seo", "role:git", "role:brainstormer". A specific project agent id (as returned by backlog_list) also works but is machine-local. Pass an empty string to unassign. Setting an agent also clears any team assignment on the task so the chosen agent actually runs it.',
        },
        teamId: {
          type: 'string',
          description:
            'Agent TEAM that runs THIS task, instead of a single agent, applied when it moves to "in_progress". Ids come from teams_list / teams_get. Mutually exclusive with `agentId`: assigning a team clears any agent on the task (mirrors how assigning an agent clears the team), so the team pipeline runs it. Pass an empty string to unassign the team.',
        },
        position: {
          type: 'number',
          description:
            'New ordinal position within the task\'s column (0 = top). Lower numbers render higher. Use `backlog_reorder` when you need to set positions for several tasks at once.',
        },
        ...WORKTREE_PROPERTIES,
        ...EXECUTOR_PROPERTIES,
      },
      required: ['taskId'],
    },
  },
  {
    name: 'backlog_delete',
    description:
      'Delete a backlog task permanently. DESTRUCTIVE and NOT undoable: the ticket goes, and with it its images, its links to other tickets and — for a ticket reported by a user (source "user_feedback") — its public page, its upvotes and the whole conversation thread with the reporter. Only call it when the user explicitly asked for THAT ticket to be removed ("delete this ticket", "drop the duplicate"); if the idea is yours, propose it and wait for a yes. One ticket per call: never sweep the board, and never "clean up" a backlog on your own initiative. When the point is that the work will not be done, prefer `backlog_update` to status "done" (the board has a won\'t-fix flow that keeps the history and informs the reporter). Acts on the CURRENT project only.',
    inputSchema: {
      type: 'object',
      properties: {
        taskId: {
          type: 'string',
          description: 'Id of the task to delete, as returned by backlog_list / backlog_get.',
        },
      },
      required: ['taskId'],
    },
  },
  {
    name: 'backlog_reorder',
    description:
      'Reorder backlog tasks by priority: top-to-bottom order is taken straight from `taskIds`. Use this when the user says things like "prioritize the backlog", "move X to the top", "put these tickets in this order", or when you need to slot a freshly-created ticket above existing ones. By default the listed tasks are repositioned WITHIN their current statuses; pass `status` to also move them all into a specific column (e.g. `status: "todo"` to gather everything in the todo column). Tasks not listed in `taskIds` keep their relative order and are pushed below the listed ones in their respective columns.',
    inputSchema: {
      type: 'object',
      properties: {
        taskIds: {
          type: 'array',
          items: { type: 'string' },
          description:
            'Task ids in the desired top-to-bottom priority order. The first id ends up at position 0 (top of column).',
        },
        status: {
          type: 'string',
          enum: ['todo', 'in_progress', 'pending', 'done'],
          description:
            'Optional target column for ALL listed tasks. Omit to keep each task in its current column.',
        },
      },
      required: ['taskIds'],
    },
  },
  {
    name: 'backlog_start_next',
    description:
      'Start the NEXT task on the backlog: takes the highest-priority ticket in the TODO column (top of the column, i.e. the lowest `position`) and launches it — moves it to "in_progress" and spawns its execution agent in a visible terminal tab, exactly like a user clicking the "Start Next" button. This is the agent-side way to "proceed with one click" through a prioritized backlog: reorder with `backlog_reorder` first (to set what "next" is), then call this to run the top ticket. Requires the desktop app to be running (it owns the terminal). Takes no arguments — it always acts on the current project. Returns the started task (id, title) and the spawned agent id. If the TODO column is empty, it reports that and starts nothing.',
    inputSchema: {
      type: 'object',
      properties: {},
    },
  },
  {
    name: 'backlog_publish_mockup',
    description:
      'Publish a self-contained HTML mockup for a feedback backlog task and get back a shareable preview link. By DEFAULT the link is returned to YOU (and the human running you) for review; it is NOT sent to the reporter. Print the returned url so the human can open it. Only set notifyClient:true when the human explicitly asked to send the mockup to the reporter directly (it then appears in their in-app chat). Provide the full HTML document (inline CSS/JS, no external resources).',
    inputSchema: {
      type: 'object',
      properties: {
        taskId: { type: 'string', description: 'The backlog task id' },
        html: { type: 'string', description: 'The full self-contained HTML document' },
        notifyClient: {
          type: 'boolean',
          description:
            'Default false. When true, deliver the mockup to the reporter in their in-app chat. Leave false to just get the link for the human to review and share themselves.',
        },
      },
      required: ['taskId', 'html'],
    },
  },
  {
    name: 'backlog_comment',
    description:
      'Post a team reply on a feedback ticket\'s conversation thread (the "Conversation with the reporter"). The reply is published on the public ticket page and the reporter (plus every follower) is notified by EMAIL immediately, so this is an outbound message to a real person: ASK THE USER FIRST unless they explicitly told you to reply to the reporter (e.g. "answer them", "ask the reporter for details", "tell them it shipped"). Only works on tickets reported by a user through the public backlog (source "user_feedback"); internal tasks have no thread. Write in the reporter\'s language, plain text, no signature (the thread already labels it "Team").',
    inputSchema: {
      type: 'object',
      properties: {
        taskId: { type: 'string', description: 'The backlog task id' },
        content: {
          type: 'string',
          description: 'The reply text (1-4000 characters), in the reporter\'s language.',
        },
      },
      required: ['taskId', 'content'],
    },
  },

  // ── Prompts ──
  {
    name: 'prompts_list',
    description:
      'List the project Prompt Library (folders + prompts). Call this BEFORE asking the user to repeat a complex setup: a saved prompt may already cover their request. Returns titles, tags, folder ids and a content preview.',
    inputSchema: {
      type: 'object',
      properties: {
        folderId: {
          type: 'string',
          description: 'Filter prompts to a specific folder (omit to list everything).',
        },
        ...PROJECT_REF_PROPERTIES,
      },
    },
  },
  {
    name: 'prompts_get',
    description:
      'Return the full content of a single prompt by id (the body, all tags, linkedPromptIds). Use after `prompts_list` when you need to actually run / inject the prompt.',
    inputSchema: {
      type: 'object',
      properties: { promptId: { type: 'string' }, ...PROJECT_REF_PROPERTIES },
      required: ['promptId'],
    },
  },
  {
    name: 'prompts_save',
    description:
      'Save a new prompt to the Prompt Library. ASK THE USER FIRST — never save silently. The right pattern is: spot a complex/reusable brief, propose "want me to save this to your Prompt Library?", wait for a yes, then call this tool. If the user already said "save this prompt", you have consent. Set `isPersonal: true` for prompts that should stay machine-local (gitignored).',
    inputSchema: {
      type: 'object',
      properties: {
        title: { type: 'string' },
        content: { type: 'string' },
        folderId: { type: 'string', description: 'Target folder id, or omit for root.' },
        tags: { type: 'array', items: { type: 'string' } },
        isPersonal: {
          type: 'boolean',
          description: 'If true, prompt is stored only locally (never committed).',
        },
        author: { type: 'string', description: 'Your agent display name and role, e.g. "Full-Stack Developer". Always pass it so the library shows who wrote what.' },
        role: { type: 'string', description: 'Your canonical role id, one of: fullstack, frontend, backend, architect, qa, devops, security, mobile, pm, marketing, git, seo, i18n, brainstormer, custom (the filename of your role file). Enables your role avatar next to the prompt.' },
      },
      required: ['title', 'content'],
    },
  },

  // ── Skills ──
  {
    name: 'skills_list',
    description:
      'List the project Skills Library (folders + skills). Skills are modular instructions (a description trigger + markdown body) that the user has explicitly attached to a task or made available project-wide. Call this BEFORE starting work on a task: a skill may already encode the exact procedure the user wants. Returns name, description (the trigger telling you when to use it), tags, folderId and a content preview.',
    inputSchema: {
      type: 'object',
      properties: {
        folderId: {
          type: 'string',
          description: 'Filter skills to a specific folder (omit to list everything).',
        },
        ...PROJECT_REF_PROPERTIES,
      },
    },
  },
  {
    name: 'skills_get',
    description:
      'Return the full content of a single skill by id (the body markdown, allowed-tools, tags). Use after `skills_list` when the description matched the task and you need the actual procedure to follow.',
    inputSchema: {
      type: 'object',
      properties: { skillId: { type: 'string' }, ...PROJECT_REF_PROPERTIES },
      required: ['skillId'],
    },
  },
  {
    name: 'skills_save',
    description:
      'Save a skill to the project Skills Library (UPSERT by name: if a skill with the same slugified name exists, it is UPDATED in place — id, position and contributor history are preserved; otherwise a new skill is created). ASK THE USER FIRST — never save silently. The right pattern is: spot a recurring procedure or recipe, propose "want me to save this as a skill?", wait for a yes, then call this tool. If the user already said "save this as a skill" or "update that skill", you have consent. To update an existing skill: call skills_get first, then pass the SAME name with the revised description/content (fields you omit keep their stored value, except description and content which are always required). The `description` field is critical — it is the trigger that tells future agents when to use this skill, write it as "Use when …" or "Use to …". Set `isPersonal: true` for skills that should stay machine-local (gitignored).',
    inputSchema: {
      type: 'object',
      properties: {
        name: { type: 'string', description: 'Slug-safe skill name (e.g. "react-component-builder"). Same name = update that skill in place.' },
        description: { type: 'string', description: 'When to use this skill — the trigger description.' },
        content: { type: 'string', description: 'Body markdown — the actual procedure.' },
        allowedTools: { type: 'array', items: { type: 'string' }, description: 'Optional list of tool names this skill needs.' },
        folderId: { type: 'string', description: 'Target folder id, or omit for root.' },
        tags: { type: 'array', items: { type: 'string' } },
        isPersonal: {
          type: 'boolean',
          description: 'If true, skill is stored only locally (never committed).',
        },
        author: { type: 'string', description: 'Your agent display name and role, e.g. "Full-Stack Developer". Always pass it so the library shows who wrote what.' },
        role: { type: 'string', description: 'Your canonical role id, one of: fullstack, frontend, backend, architect, qa, devops, security, mobile, pm, marketing, git, seo, i18n, brainstormer, custom (the filename of your role file). Enables your role avatar next to the skill.' },
      },
      required: ['name', 'description', 'content'],
    },
  },

  // ── Teams + saved agents (schemas defined in teamsMcpTools.cjs) ──
  ...TEAM_TOOLS,

  {
    name: 'agents_spawn',
    description:
      'Spawn a NEW agent on this project and hand it a brief, exactly like the user clicking the quick-agent (lightning) button: a throwaway agent appears in a visible terminal tab and starts on your prompt the moment its CLI is up. This is how you get a colleague: a second pair of eyes on a design, a specialist for a subsystem you should not be touching yourself, or parallel work on an independent part of the task. It is NOT how you delegate browser testing — that is `run_qa_test`, and it is the only tool that gets browser control. WHEN TO CALL IT: the user asked for another agent ("spawn a security expert", "get a second opinion", "have someone else do X"), or you are about to do a large chunk of work that is genuinely independent from yours and would be better run in parallel. Your own initiative on anything else: ask the user first, one short question. The spawned agent is EPHEMERAL and shares this project checkout unless you ask for a worktree, so never spawn two agents to edit the same files. It does not see your conversation: write a self-contained brief (what to do, where, what "done" looks like). Requires the desktop app to be running. Returns the spawned agent id, its resolved role/provider/model, and any override that had to be dropped.',
    inputSchema: {
      type: 'object',
      properties: {
        prompt: {
          type: 'string',
          description:
            'The self-contained brief handed to the agent as its very first message. It has none of your context: state the goal, the files or area to work in, and what counts as done.',
        },
        role: {
          type: 'string',
          description:
            'Canonical built-in role id: fullstack, frontend, backend, architect, qa, devops, security, mobile, pm, marketing, git, seo, i18n, brainstormer. Omit to use the project\'s Quick Agent profile. Ignored when `agentId` is set.',
        },
        agentId: {
          type: 'string',
          description:
            'Spawn a copy of one of the project\'s SAVED agents (its role, persona, skills and model) instead of a bare role. Call `agents_list` to get the ids. The saved agent itself is never touched or interrupted.',
        },
        name: {
          type: 'string',
          description: 'Display name of the spawned agent, e.g. "Auth refactor review". Defaults to the role name.',
        },
        useWorktree: {
          type: 'boolean',
          description:
            'Run the agent in its own git worktree instead of the shared project checkout. Use it whenever the agent will EDIT files while you are also editing, otherwise you will fight over the working copy. Requires `worktreeBranch`.',
        },
        worktreeBranch: { type: 'string', description: 'Branch of the dedicated worktree, e.g. "agents/auth-review".' },
        provider: {
          type: 'string',
          description: 'Agent CLI to run it with (e.g. claude, codex, opencode). Omit to inherit the project default.',
        },
        model: {
          type: 'string',
          description:
            'Model id, validated against the resolved provider. A model belonging to another provider is dropped (and reported) rather than breaking the launch. Prefer a cheaper model for narrow, mechanical work.',
        },
        reasoningEffort: { type: 'string', description: 'Reasoning effort, when the provider exposes one (e.g. low, medium, high).' },
        skillIds: {
          type: 'array',
          items: { type: 'string' },
          description: 'Skills from the project library to attach to the agent. Call `skills_list` for the ids.',
        },
      },
      required: ['prompt'],
    },
  },

  // ── Project Memory ──
  {
    name: 'memory_list',
    description:
      'List the Project Memory: the persistent shared knowledge base of this project (folders + notes). It is stored server-side and shared by every agent and every user of the project, so it may contain knowledge written by others. Call this at the START of any non-trivial task, before exploring the codebase: a note may already explain the module you are about to read, a past decision, or a pitfall to avoid. Returns name, description (the retrieval cue), folder, tags and a content preview per note.',
    inputSchema: {
      type: 'object',
      properties: {
        folderId: {
          type: 'string',
          description: 'Filter notes to a specific folder (omit to list everything).',
        },
        ...PROJECT_REF_PROPERTIES,
      },
    },
  },
  {
    name: 'memory_get',
    description:
      'Return the full markdown body of a single memory note. Use after `memory_list` when a description matches your task, or to follow a [[wiki-link]] found in another note. Accepts the note id or its exact name.',
    inputSchema: {
      type: 'object',
      properties: {
        note: { type: 'string', description: 'Note id, or its exact (slugified) name.' },
        ...PROJECT_REF_PROPERTIES,
      },
      required: ['note'],
    },
  },
  {
    name: 'memory_save',
    description:
      'Create or update a note in the Project Memory (UPSERT by name: if a note with the same name exists, it is updated in place; ALWAYS prefer enriching an existing note over creating a near-duplicate). Write after significant outcomes, but raise the bar: only persist what a competent agent would genuinely re-hit later; a routine bug fixed in passing is not worth a note. The memory has exactly TWO roots. (1) "features": ONE note per feature or area of the app, the NOTE named after the feature ("team-run", "billing", "seo"), with sections inside (## Architecture, ## Decisions, ## Pitfalls, ## State) that you keep enriching. A pitfall is that note\'s Pitfalls section, a decision its Decisions section. Use a "features/<feature>" sub-folder only when one feature genuinely needs several notes. (2) "global": knowledge that belongs to no single feature, split by kind into "global/architecture" (system-wide), "global/conventions" (repo-wide rules) and "global/pitfalls" (traps in tooling, infra, build, deploy). Any other `folder` value is rewritten into that layout automatically, so pass a canonical path and check the `folder` echoed back in the response. Reuse the EXISTING note that already covers your topic (memory_list shows every note with its full path) instead of coining a synonym. The `description` is the retrieval cue for future agents, phrase it "Contains ...". Link related notes with [[note-name]] wiki-links inside the body: they draw the project memory graph. Store distilled durable knowledge, never session chatter, machine-specific paths, or secrets.',
    inputSchema: {
      type: 'object',
      properties: {
        name: { type: 'string', description: 'Slug-safe note name (e.g. "auth-token-refresh"). Same name = update that note.' },
        description: { type: 'string', description: 'Retrieval cue: what this note contains and when to read it.' },
        content: { type: 'string', description: 'Body markdown: the distilled knowledge. Use [[note-name]] links to related notes.' },
        folder: { type: 'string', description: 'Canonical path. "features" for a feature note (the NOTE name carries the feature, so no extra folder), "features/<feature>" only for a feature that needs several notes, or "global/architecture" | "global/conventions" | "global/pitfalls" for cross-cutting knowledge. Anything else is normalized into that layout (a kind name lands under "global/", any other name under "features"). Missing segments are created. Omit to keep an existing note where it is; a new note defaults to "features".' },
        tags: { type: 'array', items: { type: 'string' } },
        author: { type: 'string', description: 'Your agent display name and role, e.g. "Full-Stack Developer". Always pass it so the memory shows who wrote what.' },
        role: { type: 'string', description: 'Your canonical role id, one of: fullstack, frontend, backend, architect, qa, devops, security, mobile, pm, marketing, git, seo, i18n, brainstormer, custom (the filename of your role file). Enables your role avatar next to the memory.' },
      },
      required: ['name', 'description', 'content'],
    },
  },

  // ── Commands ──
  {
    name: 'commands_list',
    description:
      'List the dev commands available here (long-running processes like "yarn dev", and one-shot snippets). ALWAYS call this FIRST whenever the user wants to launch ANY process — the workflow they need is very likely already saved (the default Terminal command is always present). Covers BOTH scopes: commands saved in this project and the user account-level ones, available in every project (each entry carries `scope`: "project" or "global"). Returns name, command, cwd, kind, category and folder per entry. After listing: if a match exists, launch it with `commands_run`; if nothing matches, propose `commands_create`. NEVER fall back to running the command via Bash — that bypasses the desktop terminal-tab system the user expects.',
    inputSchema: { type: 'object', properties: {} },
  },
  {
    name: 'commands_get',
    description: 'Return a single dev command by id with its full configuration.',
    inputSchema: {
      type: 'object',
      properties: { commandId: { type: 'string' } },
      required: ['commandId'],
    },
  },
  {
    name: 'commands_run',
    description:
      'Launch a saved dev command in the desktop UI: spawns a real PTY in a Dev Commands terminal tab the user can see, monitor, and stop with one click. This is the ONLY correct way for an agent to start a long-running process — never use the Bash tool for dev servers, watchers or builders. When the user said "start X" / "run the dev server" / "lance Y", that IS the order — call this directly after a single `commands_list`, do NOT echo back "want me to start it?" (over-asking is friction). Only ask when there is a genuine ambiguity (multiple matches → which one) or when YOU are the one proposing to launch something the user did not request. Typical flow: `commands_list` → exactly one match → `commands_run({ commandId })`. If nothing matches, propose to add it AND start it in one shot, then `commands_create` → `commands_run`. Returns the spawned ptyId. Tell the user the terminal tab is now visible in the Dev Commands panel.',
    inputSchema: {
      type: 'object',
      properties: { commandId: { type: 'string' } },
      required: ['commandId'],
    },
  },
  {
    name: 'commands_create',
    description:
      'Create a new dev command so the user can launch a recurring process or snippet from the desktop UI in one click. ASK THE USER FIRST — say what you want to save and where ("Want me to save \'pnpm api:dev\' in ./api as a new dev command?"), wait for confirmation, then call. ALWAYS run `commands_list` before proposing a new one — the workflow is very likely already saved (the default Terminal command is always present), and reusing it is ALWAYS preferred. After creating, tell the user to launch it from the Dev Commands panel (the create call does NOT auto-start the process). Do NOT then run the same command via Bash — saving and bash-running the same thing defeats the whole point: the user wants the saved command to be the way the process runs. If you are unsure of the exact command, working directory, or kind, ASK rather than guess. Set `kind: "process"` for long-running servers (default), `"snippet"` for one-shot scripts. `cwd` is relative to the project root (e.g. "./back").',
    inputSchema: {
      type: 'object',
      properties: {
        name: { type: 'string', description: 'Display name (e.g. "Frontend Dev").' },
        command: { type: 'string', description: 'Shell command (e.g. "yarn dev").' },
        cwd: { type: 'string', description: 'Relative path from project root. Default ".".' },
        kind: {
          type: 'string',
          enum: ['process', 'snippet'],
          description: '"process" = long-running, "snippet" = one-shot. Default "process".',
        },
        category: {
          type: 'string',
          description: 'Icon hint (e.g. "react", "nextjs", "node", "docker", "other").',
        },
        folderId: { type: 'string', description: 'Target folder id, or omit for root.' },
        autoExec: {
          type: 'boolean',
          description: 'If true, the command auto-runs when the user clicks it. Default true.',
        },
        isPersonal: { type: 'boolean', description: 'If true, gitignored.' },
        scope: {
          type: 'string',
          enum: ['project', 'global'],
          description:
            '"project" (default) saves it in this project only. "global" saves it on the user account, making it available in EVERY project: only use it when the user explicitly wants that (a personal tunnel, a shared stack), never to work around a project-level command.',
        },
      },
      required: ['name', 'command'],
    },
  },

  // ── SSH connections ──
  {
    name: 'ssh_list',
    description:
      'List the SSH connections the user saved in AgentsRoom and that are usable here (this project plus the account-level ones). Returns metadata only: id, name, host, port, user, auth type, remote path, description and `scope`. Passwords, passphrases and private keys are NEVER exposed: they stay encrypted in the OS keychain and are injected by the desktop app itself. Call this whenever the user talks about "the server", "prod", "the VPS", deploying or reading remote logs, before assuming you have to build an ssh command by hand.',
    inputSchema: { type: 'object', properties: {} },
  },
  {
    name: 'ssh_connect',
    description:
      'Open a saved SSH connection as a real terminal tab in the desktop app (the user sees it, can type in it and close it). This is the ONLY correct way for an agent to reach a remote machine: the credential is injected by the main process, so you never handle a secret. Flow: `ssh_list` → one match → `ssh_connect({ connectionId })`. When the user said "connect to prod" / "open a shell on the server", that IS the order: do not ask again. Ask only on real ambiguity (several matching servers), or when YOU are the one proposing a connection the user did not request. Anything destructive on the remote host must still be confirmed by the user.',
    inputSchema: {
      type: 'object',
      properties: { connectionId: { type: 'string' } },
      required: ['connectionId'],
    },
  },

  // ── App settings ──
  ...SETTINGS_TOOLS,
];

// ── ID helpers (mirror the desktop store helpers) ─────────────────────────

function nanoidPrompt() {
  return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}
function nanoidCmd() {
  return `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function nanoidSkill() {
  return `skl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}

// Minimal slug normalization for the YAML `name` field of a skill.
// Mirrors the behaviour Claude expects: lowercase, ascii alnum, hyphens.
function slugifySkillName(input) {
  const s = String(input || '')
    .toLowerCase()
    .normalize('NFKD')
    .replace(/[̀-ͯ]/g, '')
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .replace(/-{2,}/g, '-');
  return s || `skill-${Date.now().toString(36)}`;
}

// ── Tool implementations ──────────────────────────────────────────────────

/**
 * Pick the worktree settings out of a tool call, keeping "omitted" distinct
 * from "set to false": only the keys the caller actually passed end up in the
 * payload, so an update never resets a setting the caller didn't mention.
 * The branch is left to the API when absent (it slugifies the task title).
 */
function worktreePatch(args) {
  const patch = {};
  if (typeof args.useWorktree === 'boolean') patch.useWorktree = args.useWorktree;
  if (typeof args.worktreeBranch === 'string' && args.worktreeBranch.trim()) {
    patch.worktreeBranch = args.worktreeBranch.trim();
  }
  if (typeof args.autoMergeWorktree === 'boolean') {
    patch.autoMergeWorktree = args.autoMergeWorktree;
  }
  return patch;
}

/**
 * Pick the executor overrides out of a tool call, keeping "omitted" (leave the
 * stored value alone) distinct from "explicitly emptied" (clear the override,
 * back to inheriting from the executor). The API applies the same contract.
 */
// Keep in sync with src/models/types.ts::REASONING_EFFORTS and the server-side
// sanitizer in site/src/lib/backlog-executor.ts.
const REASONING_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];

function executorPatch(args) {
  const patch = {};
  for (const key of ['provider', 'model', 'accountProfileId']) {
    if (typeof args[key] === 'string') patch[key] = args[key].trim() || null;
  }
  if (typeof args.reasoningEffort === 'string') {
    const effort = args.reasoningEffort.trim().toLowerCase();
    patch.reasoningEffort = REASONING_EFFORTS.includes(effort) ? effort : null;
  }
  if (typeof args.skillsMode === 'string') {
    patch.skillsMode = args.skillsMode.trim().toLowerCase() === 'replace' ? 'replace' : null;
  }
  if (Array.isArray(args.skillIds)) {
    patch.skillIds = args.skillIds.filter((id) => typeof id === 'string' && id.trim());
  }
  return patch;
}

/**
 * Resolve what a task will ACTUALLY run with, and report the overrides that
 * cannot apply. Mirrors the desktop resolver (src/models/taskExecutor.ts) for
 * the part that is knowable from the API: ticket override, then the assigned
 * agent's configuration. Everything below that (the project's Backlog Agent
 * profile and the system defaults) is machine-local, so those fields come back
 * as "project or system default" rather than a guessed value.
 *
 * Model/provider compatibility can only be fully checked where the provider
 * catalogue lives (the desktop app, which knows the locally installed CLIs and
 * their discovered models), so what is checked here is the mismatch an agent
 * can actually cause: pinning a model without switching the provider that runs
 * it, and attaching skills that no longer exist.
 */
async function resolveTaskExecution(task, projectId = PROJECT_ID) {
  const overrides = {
    provider: task.provider || null,
    model: task.model || null,
    reasoningEffort: task.reasoningEffort || null,
    accountProfileId: task.accountProfileId || null,
    skillIds: Array.isArray(task.skillIds) ? task.skillIds : [],
    skillsMode: task.skillsMode === 'replace' ? 'replace' : 'merge',
  };

  let agent = null;
  if (task.agentId && !String(task.agentId).startsWith('role:')) {
    try {
      const data = await apiGet(`/api/project-agents?projectId=${encodeURIComponent(projectId)}`);
      const agents = Array.isArray(data && data.agents) ? data.agents : [];
      agent = agents.find((a) => a.id === task.agentId) || null;
    } catch (_) {
      // Agent lookup is best-effort: a network hiccup must not break the read.
    }
  }

  const pick = (ticketValue, agentValue) =>
    ticketValue
      ? { value: ticketValue, source: 'ticket' }
      : agentValue
        ? { value: agentValue, source: 'agent' }
        : { value: null, source: 'project-or-system-default' };

  const provider = pick(overrides.provider, agent && agent.provider);
  const model = pick(overrides.model, agent && agent.model);
  const account = pick(
    overrides.accountProfileId,
    agent && (agent.claudeProfileId || agent.codexProfileId),
  );

  const agentSkillIds = agent && Array.isArray(agent.defaultSkillIds) ? agent.defaultSkillIds : [];
  const skillIds =
    overrides.skillsMode === 'replace'
      ? overrides.skillIds
      : [...new Set([...agentSkillIds, ...overrides.skillIds])];

  const validation = [];
  // A model pinned on a ticket whose effective provider comes from an agent
  // running another CLI is the classic silent failure: the CLI rejects the id
  // at launch and the run falls back, so surface it before execution.
  if (overrides.model && !overrides.provider && agent && agent.provider) {
    validation.push(
      `The ticket pins model "${overrides.model}" but no provider, so it runs on the agent's provider ("${agent.provider}"). If that model belongs to another provider, set \`provider\` too, otherwise the desktop drops the model at launch and uses the agent's.`,
    );
  }
  if (overrides.skillIds.length > 0) {
    try {
      const data = await apiGet(`/api/project-skills?projectId=${encodeURIComponent(projectId)}`);
      const known = new Set((Array.isArray(data && data.skills) ? data.skills : []).map((s) => s.id));
      // The endpoint only knows PROJECT skills; a global-library id is legit and
      // simply unknown here, so this is reported as a check to run, not a fault.
      const unknown = overrides.skillIds.filter((id) => !known.has(id));
      if (unknown.length > 0) {
        validation.push(
          `Skill ids not found in the project library: ${unknown.join(', ')}. They are either global-library skills (fine) or stale ids that will be skipped at launch. Call skills_list to confirm.`,
        );
      }
    } catch (_) {
      // Same rationale as the agent lookup: never fail the read on this.
    }
  }
  if (overrides.accountProfileId) {
    validation.push(
      'Account profile ids are machine-local: on any other machine this ticket falls back to its executor account.',
    );
  }

  return {
    executorOverrides: overrides,
    effectiveExecutor: {
      provider: provider.value,
      providerSource: provider.source,
      model: model.value,
      modelSource: model.source,
      // Reasoning effort has no agent-level equivalent today: a ticket is the
      // only thing that can pin one.
      reasoningEffort: overrides.reasoningEffort,
      reasoningEffortSource: overrides.reasoningEffort ? 'ticket' : 'model-default',
      accountProfileId: account.value,
      accountProfileIdSource: account.source,
      skillIds,
      skillsMode: overrides.skillsMode,
      agentId: task.agentId ?? null,
      agentName: agent ? agent.customName || agent.role : null,
    },
    validation,
  };
}

async function toolProjectsList() {
  const projects = await fetchAccountProjects();
  if (!projects) {
    return [{
      type: 'text',
      text: JSON.stringify({
        current: { id: PROJECT_ID, name: null },
        count: 1,
        projects: [{ id: PROJECT_ID, name: null, current: true }],
        warning:
          'The account project registry is unreachable (offline, or not signed in to AgentsRoom). Only the current project is addressable right now.',
      }, null, 2),
    }];
  }
  // The current project may legitimately be missing from the registry (never
  // synced, or the user is signed out on this machine): it stays addressable.
  const rows = (projects.some((p) => p.id === PROJECT_ID)
    ? projects
    : [{ id: PROJECT_ID, name: null }, ...projects]
  ).map((p) => ({ id: p.id, name: p.name, current: p.id === PROJECT_ID }));
  const current = rows.find((r) => r.current) || { id: PROJECT_ID, name: null };
  return [{
    type: 'text',
    text: JSON.stringify({
      current: { id: current.id, name: current.name },
      count: rows.length,
      projects: rows,
    }, null, 2),
  }];
}

async function toolBacklogList(args) {
  const status = typeof args.status === 'string' ? args.status : null;
  const limit = typeof args.limit === 'number' ? args.limit : 50;
  const target = await resolveProjectRef(args.project);
  const data = await apiGet(`/api/backlog?projectId=${encodeURIComponent(target.id)}`);
  const tasks = Array.isArray(data && data.tasks) ? data.tasks : [];
  const filtered = status ? tasks.filter((t) => t.status === status) : tasks;
  const trimmed = filtered.slice(0, limit).map((t) => ({
    id: t.id,
    title: t.title,
    status: t.status,
    ticketType: t.ticketType,
    tags: t.tags || [],
    agentId: t.agentId ?? null,
    teamId: t.teamId ?? null,
    useWorktree: t.useWorktree === true,
    worktreeBranch: t.worktreeBranch ?? null,
    // Executor overrides pinned on the ticket (null = inherited from its
    // executor). `backlog_get` resolves them into the effective configuration.
    provider: t.provider ?? null,
    model: t.model ?? null,
    updatedAt: t.updatedAt,
  }));
  return [{
    type: 'text',
    text: JSON.stringify(
      withProjectStamp(target, { count: trimmed.length, total: tasks.length, tasks: trimmed }),
      null,
      2,
    ),
  }];
}

async function toolBacklogGet(args) {
  const taskId = String(args.taskId || '');
  if (!taskId) return [{ type: 'text', text: 'taskId is required' }];
  const target = await resolveProjectRef(args.project);
  // The list endpoint returns full task bodies, including descriptions.
  const data = await apiGet(`/api/backlog?projectId=${encodeURIComponent(target.id)}`);
  const tasks = Array.isArray(data && data.tasks) ? data.tasks : [];
  const found = tasks.find((t) => t.id === taskId);
  if (!found) {
    return [{ type: 'text', text: `Task ${taskId} not found in project ${target.name || target.id}.` }];
  }
  const execution = await resolveTaskExecution(found, target.id);
  return [{
    type: 'text',
    text: JSON.stringify(withProjectStamp(target, { task: found, ...execution }), null, 2),
  }];
}

async function toolBacklogCreate(args) {
  const title = String(args.title || '').trim();
  if (!title) return [{ type: 'text', text: 'title is required (non-empty string).' }];
  // Agent and team assignment are mutually exclusive: a team runs the ticket as
  // a multi-agent pipeline instead of a single agent. If a caller passes both,
  // the team wins and the agent is dropped, mirroring backlog_update.
  const createAgentId = typeof args.agentId === 'string' && args.agentId.trim() ? args.agentId.trim() : null;
  const createTeamId = typeof args.teamId === 'string' && args.teamId.trim() ? args.teamId.trim() : null;
  const payload = {
    projectId: PROJECT_ID,
    title,
    description: typeof args.description === 'string' ? args.description : '',
    status: typeof args.status === 'string' ? args.status : 'todo',
    ticketType: typeof args.ticketType === 'string' ? args.ticketType : null,
    tags: Array.isArray(args.tags) ? args.tags.filter((t) => typeof t === 'string') : [],
    agentId: createTeamId ? null : createAgentId,
    teamId: createTeamId,
    // Creation attribution shown on the board card (self-declared, portable).
    createdBy: buildAgentEditedBy(args),
    ...worktreePatch(args),
    ...executorPatch(args),
  };
  const res = await apiPost('/api/backlog', payload);
  const task = res && res.task ? res.task : null;
  // Report how the fresh ticket will actually run, so an agent that pinned an
  // override sees right away whether it holds up against the assigned agent.
  const execution = task ? await resolveTaskExecution(task) : null;
  return [{ type: 'text', text: JSON.stringify({ ok: true, task, ...(execution ?? {}) }, null, 2) }];
}

async function toolBacklogUpdate(args) {
  const taskId = String(args.taskId || '');
  if (!taskId) return [{ type: 'text', text: 'taskId is required' }];
  const patch = {};
  if (typeof args.title === 'string') patch.title = args.title;
  if (typeof args.description === 'string') patch.description = args.description;
  if (typeof args.status === 'string') patch.status = args.status;
  if (typeof args.ticketType === 'string') patch.ticketType = args.ticketType;
  if (Array.isArray(args.tags)) patch.tags = args.tags.filter((t) => typeof t === 'string');
  if (typeof args.agentId === 'string') {
    const agentId = args.agentId.trim();
    patch.agentId = agentId || null;
    // Agent and team assignment are mutually exclusive (mirrors the UI). When
    // an agent is assigned, clear any team so the chosen agent actually runs
    // the task: at spawn time the team run is evaluated BEFORE the agent, so a
    // lingering teamId would otherwise win and shadow this assignment.
    if (agentId) patch.teamId = null;
  }
  if (typeof args.teamId === 'string') {
    const teamId = args.teamId.trim();
    patch.teamId = teamId || null;
    // Symmetric with agentId above: assigning a team clears any single-agent
    // assignment so the team pipeline runs the task. Processed after agentId,
    // so a team wins when a caller passes both.
    if (teamId) patch.agentId = null;
  }
  if (typeof args.position === 'number' && Number.isFinite(args.position)) {
    patch.position = args.position;
  }
  Object.assign(patch, worktreePatch(args), executorPatch(args));
  if (Object.keys(patch).length === 0) {
    return [{ type: 'text', text: 'No fields to update. Pass at least one of title, description, status, ticketType, tags, agentId, teamId, position, useWorktree, worktreeBranch, autoMergeWorktree, provider, model, reasoningEffort, accountProfileId, skillIds, skillsMode.' }];
  }
  const updated = await apiPatch(`/api/backlog/${encodeURIComponent(taskId)}`, patch);
  // Echo the resolved execution when an executor override was touched: that is
  // the moment the caller needs to know whether the pin actually holds.
  const touchedExecutor = ['provider', 'model', 'reasoningEffort', 'accountProfileId', 'skillIds', 'skillsMode']
    .some((key) => key in patch);
  const task = updated && updated.task ? updated.task : null;
  const execution = touchedExecutor && task ? await resolveTaskExecution(task) : null;
  return [{ type: 'text', text: JSON.stringify({ ok: true, taskId, patch, ...(execution ?? {}) }, null, 2) }];
}

async function toolBacklogDelete(args) {
  const taskId = String(args.taskId || '').trim();
  if (!taskId) return [{ type: 'text', text: 'taskId is required' }];

  // Pre-flight read, for two reasons.
  // (1) SCOPE. `DELETE /api/backlog/:id` is scoped by userId alone, so an id
  //     copied from a SIBLING project of the same account would be deleted
  //     there — silently, and against the rule that cross-project support is
  //     read-only. Resolving the id inside the current project's backlog is
  //     what keeps the write local.
  // (2) REPORTING. The route answers `{ ok: true }` with no body: what was
  //     destroyed can only be told back to the caller if it was read first.
  const data = await apiGet(`/api/backlog?projectId=${encodeURIComponent(PROJECT_ID)}`);
  const tasks = Array.isArray(data && data.tasks) ? data.tasks : [];
  const task = tasks.find((t) => t && t.id === taskId);
  if (!task) {
    return [{
      type: 'text',
      text: `Task ${taskId} is not in this project's backlog: already deleted, or it belongs to another project (deletion never crosses projects). Nothing was deleted — check the id with backlog_list.`,
    }];
  }

  await apiDelete(`/api/backlog/${encodeURIComponent(taskId)}`);

  // Consequences the caller must relay to the human: both are invisible from
  // the tool result alone, and neither can be undone.
  const warnings = [];
  if (task.source === 'user_feedback') {
    warnings.push(
      'This ticket was reported by a user through the public backlog: its public page and the conversation thread with the reporter are gone too. Say so, and note there is no way left to answer them on that thread.',
    );
  }
  if (task.status === 'in_progress') {
    warnings.push(
      'The ticket was in progress: deleting it does NOT stop the agent running it, nor remove its git worktree. Close the terminal tab (and clean the worktree) if that work must stop.',
    );
  }

  return [{
    type: 'text',
    text: JSON.stringify({
      ok: true,
      deleted: {
        id: task.id,
        title: task.title,
        status: task.status,
        source: task.source || 'classic',
      },
      ...(warnings.length ? { warnings } : {}),
    }, null, 2),
  }];
}

async function toolBacklogPublishMockup(args) {
  const taskId = String(args.taskId || '').trim();
  if (!taskId) return [{ type: 'text', text: 'taskId is required' }];
  const html = String(args.html || '');
  if (!html.trim()) return [{ type: 'text', text: 'html is required' }];
  const notifyClient = args.notifyClient === true;
  const res = await apiPost(`/api/backlog/${encodeURIComponent(taskId)}/mockup`, {
    html,
    notifyClient,
  });
  const url = res && res.url ? String(res.url) : null;
  return [
    {
      type: 'text',
      text: JSON.stringify(
        {
          ok: true,
          taskId,
          url,
          notifiedClient: !!(res && res.notifiedClient),
          note: notifyClient
            ? 'Mockup sent to the reporter.'
            : 'Mockup link ready. It was NOT sent to the reporter. Share this url with the human running you.',
        },
        null,
        2,
      ),
    },
  ];
}

async function toolBacklogComment(args) {
  const taskId = String(args.taskId || '').trim();
  if (!taskId) return [{ type: 'text', text: 'taskId is required' }];
  const content = typeof args.content === 'string' ? args.content.trim() : '';
  if (!content) return [{ type: 'text', text: 'content is required' }];
  const MAX_COMMENT_LENGTH = 4000; // mirrors the API validation
  if (content.length > MAX_COMMENT_LENGTH) {
    return [{
      type: 'text',
      text: `content is too long (${content.length} chars, max ${MAX_COMMENT_LENGTH}). Shorten the reply.`,
    }];
  }

  // The thread lives on the project's public backlog, addressed by slug (same
  // resolution as the conversation prompt-injection path in main.ts).
  const rb = await apiGet(`/api/remote-backlog?projectId=${encodeURIComponent(PROJECT_ID)}`);
  const backlogs = Array.isArray(rb && rb.backlogs) ? rb.backlogs : [];
  const slug = (backlogs.find((b) => b && b.isActive) || backlogs[0] || {}).slug;
  if (!slug) {
    return [{
      type: 'text',
      text: 'This project has no public backlog, so its tickets have no reporter conversation thread to reply on.',
    }];
  }

  let res;
  try {
    res = await apiPost(
      `/api/public-backlog/${encodeURIComponent(slug)}/tickets/${encodeURIComponent(taskId)}/comments`,
      { content },
    );
  } catch (err) {
    const msg = String((err && err.message) || err);
    if (msg === 'NOT_FOUND') {
      return [{
        type: 'text',
        text: 'Task not found on the public backlog. Only user-feedback tickets (reported through the public backlog) have a conversation thread; internal tasks cannot be commented.',
      }];
    }
    if (msg === 'RATE_LIMITED') {
      return [{ type: 'text', text: 'Comment rate limit reached (20 per hour). Try again later.' }];
    }
    throw err;
  }

  const comment = res && res.comment ? res.comment : null;
  // The API decides the author side from the auth token: the project owner
  // posts as "team" (reporter + followers emailed). Any other identity would
  // land as a "client" bubble — surface it instead of silently mislabeling.
  const authorType = comment && comment.authorType ? String(comment.authorType) : null;
  return [{
    type: 'text',
    text: JSON.stringify({
      ok: true,
      taskId,
      commentId: comment && comment.id ? comment.id : null,
      authorType,
      note: authorType === 'team'
        ? 'Reply posted on the ticket thread as the team. The reporter and any followers are being notified by email.'
        : 'Reply posted, but NOT as the team (the auth token does not belong to the backlog owner). Tell the human so they can check the thread.',
    }, null, 2),
  }];
}

async function toolBacklogReorder(args) {
  const ids = Array.isArray(args.taskIds)
    ? args.taskIds.filter((id) => typeof id === 'string' && id.length > 0)
    : [];
  if (ids.length === 0) {
    return [{ type: 'text', text: 'taskIds is required (non-empty array of task ids).' }];
  }
  const targetStatus = typeof args.status === 'string' ? args.status : null;

  // Pull the live task list once so we can validate ids and resolve each
  // task's current status when the caller didn't pin a specific column.
  const data = await apiGet(`/api/backlog?projectId=${encodeURIComponent(PROJECT_ID)}`);
  const tasks = Array.isArray(data && data.tasks) ? data.tasks : [];
  const byId = new Map(tasks.map((t) => [t.id, t]));

  const missing = ids.filter((id) => !byId.has(id));
  if (missing.length === ids.length) {
    return [{
      type: 'text',
      text: `None of the provided taskIds exist in this project: ${missing.join(', ')}`,
    }];
  }

  const known = ids.filter((id) => byId.has(id));
  const knownSet = new Set(known);

  // Build the reorder payload. Strategy:
  //   - The listed ids get positions 0..n-1 in the order given (top-down).
  //   - Tasks NOT listed keep their relative order but are pushed below the
  //     listed ones in each affected column, so a partial prioritization
  //     doesn't accidentally bury existing top tasks.
  //   - When `status` is set, all listed ids move to that column too.
  const updates = [];

  // Group listed ids by their target column.
  const columnOrder = new Map(); // status -> ordered list of ids
  for (const id of known) {
    const task = byId.get(id);
    const col = targetStatus || task.status;
    if (!columnOrder.has(col)) columnOrder.set(col, []);
    columnOrder.get(col).push(id);
  }

  for (const [col, listedIds] of columnOrder.entries()) {
    // Existing column tasks (other than the listed ones), in their current order.
    const others = tasks
      .filter((t) => t.status === col && !knownSet.has(t.id))
      .sort((a, b) => (a.position ?? 0) - (b.position ?? 0));

    // For cross-column moves, also include tasks listed BUT moving INTO this
    // column from elsewhere — they're already in `listedIds`.
    let pos = 0;
    for (const id of listedIds) {
      updates.push({ id, status: col, position: pos++ });
    }
    for (const t of others) {
      updates.push({ id: t.id, status: col, position: pos++ });
    }
  }

  // When `status` is set, the listed ids LEFT their previous columns. We
  // must also recompact those source columns so the freed positions don't
  // leave gaps that confuse the kanban renderer.
  if (targetStatus) {
    const movedFromCols = new Set();
    for (const id of known) {
      const t = byId.get(id);
      if (t.status !== targetStatus) movedFromCols.add(t.status);
    }
    for (const col of movedFromCols) {
      const remaining = tasks
        .filter((t) => t.status === col && !knownSet.has(t.id))
        .sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
      remaining.forEach((t, i) => updates.push({ id: t.id, status: col, position: i }));
    }
  }

  await apiPost('/api/backlog/reorder', { tasks: updates });

  return [{
    type: 'text',
    text: JSON.stringify({
      ok: true,
      reordered: known.length,
      ignoredUnknown: missing,
      updates,
    }, null, 2),
  }];
}

async function toolBacklogStartNext() {
  // Resolve the top-of-TODO task (lowest position). Same "next" the desktop
  // "Start Next" button targets — priority order is `position` ascending.
  const data = await apiGet(`/api/backlog?projectId=${encodeURIComponent(PROJECT_ID)}`);
  const tasks = Array.isArray(data && data.tasks) ? data.tasks : [];
  const todo = tasks
    .filter((t) => t.status === 'todo')
    .sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
  const next = todo[0];
  if (!next) {
    return [{ type: 'text', text: JSON.stringify({ ok: false, reason: 'no_todo_tasks', message: 'The TODO column is empty — nothing to start.' }, null, 2) }];
  }

  // Spawning needs the desktop (PTY + terminal tab), so go through the local
  // bridge. Forward only the fields the renderer needs to resolve the base
  // agent, build the prompt and honor per-task runtime / worktree overrides.
  const task = {
    id: next.id,
    title: next.title || '',
    description: typeof next.description === 'string' ? next.description : '',
    agentId: next.agentId ?? null,
    teamId: next.teamId ?? null,
    ...(next.provider ? { provider: next.provider } : {}),
    ...(next.model ? { model: next.model } : {}),
    ...(next.reasoningEffort ? { reasoningEffort: next.reasoningEffort } : {}),
    ...(next.accountProfileId ? { accountProfileId: next.accountProfileId } : {}),
    ...(next.skillsMode ? { skillsMode: next.skillsMode } : {}),
    ...(Array.isArray(next.skillIds) && next.skillIds.length > 0 ? { skillIds: next.skillIds } : {}),
    ...(next.useWorktree ? { useWorktree: true } : {}),
    ...(next.worktreeBranch ? { worktreeBranch: next.worktreeBranch } : {}),
  };
  const result = await bridgeCall('backlog:start-next', { projectPath: PROJECT_PATH, task });
  if (!result || result.ok === false) {
    return [{ type: 'text', text: `Start failed: ${(result && result.error) || 'unknown error'}` }];
  }
  return [{
    type: 'text',
    text: JSON.stringify({
      ok: true,
      started: { id: next.id, title: task.title },
      remainingTodo: todo.length - 1,
      message: 'Task moved to In Progress and its agent is starting in a new terminal tab.',
    }, null, 2),
  }];
}

/** Hard cap on the desktop round-trip: agent creation can include a git
 *  worktree checkout, so it needs more headroom than a plain store read. Kept
 *  above the desktop-side spawn timeout so the readable "did not answer in
 *  time" error wins over a raw RPC timeout. */
const AGENT_SPAWN_RPC_TIMEOUT_MS = 90_000;

async function toolAgentsSpawn(args) {
  const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '';
  if (!prompt) {
    return [{ type: 'text', text: 'agents_spawn requires a non-empty `prompt`: the brief the spawned agent starts on.' }];
  }
  if (args.useWorktree && !args.worktreeBranch) {
    return [{ type: 'text', text: 'agents_spawn: `useWorktree` requires a `worktreeBranch` (e.g. "agents/auth-review").' }];
  }
  // Pipe: the renderer owns the role / provider / model catalogue and reports
  // back what it had to drop (same contract as the backlog executor overrides).
  const request = {
    prompt,
    ...(args.role ? { role: String(args.role) } : {}),
    ...(args.agentId ? { agentId: String(args.agentId) } : {}),
    ...(args.name ? { name: String(args.name) } : {}),
    ...(args.useWorktree ? { useWorktree: true, worktreeBranch: String(args.worktreeBranch) } : {}),
    ...(args.provider ? { provider: String(args.provider) } : {}),
    ...(args.model ? { model: String(args.model) } : {}),
    ...(args.reasoningEffort ? { reasoningEffort: String(args.reasoningEffort) } : {}),
    ...(Array.isArray(args.skillIds) && args.skillIds.length > 0 ? { skillIds: args.skillIds.map(String) } : {}),
  };
  let result;
  try {
    result = await bridgeCall(
      'agent:spawn',
      { projectPath: PROJECT_PATH, request },
      AGENT_SPAWN_RPC_TIMEOUT_MS,
    );
  } catch (err) {
    return [{ type: 'text', text: `Spawn failed: ${(err && err.message) || 'unknown error'}` }];
  }
  return [{
    type: 'text',
    text: JSON.stringify({
      ok: true,
      agent: {
        id: result.agentId,
        name: result.name,
        role: result.role,
        provider: result.provider,
        ...(result.model ? { model: result.model } : {}),
        ...(result.worktreePath ? { worktreePath: result.worktreePath } : {}),
      },
      ...(result.droppedOverrides && result.droppedOverrides.length > 0
        ? { droppedOverrides: result.droppedOverrides }
        : {}),
      message:
        'The agent is starting in its own terminal tab with your brief as its first message. It runs on its own from here: you will not be notified when it is done, so tell the user to watch its tab.',
    }, null, 2),
  }];
}

async function toolPromptsList(args) {
  const folderId = typeof args.folderId === 'string' ? args.folderId : null;
  const target = await resolveProjectRef(args.project);
  const data = await apiGet(`/api/project-prompts?projectId=${encodeURIComponent(target.id)}`);
  const folders = Array.isArray(data && data.folders) ? data.folders : [];
  const prompts = Array.isArray(data && data.prompts) ? data.prompts : [];
  const filtered = folderId === null
    ? prompts
    : prompts.filter((p) => (p.folderId || null) === folderId);
  const trimmed = filtered.map((p) => ({
    id: p.id,
    title: p.title,
    folderId: p.folderId,
    tags: p.tags || [],
    isPersonal: !!p.isPersonal,
    contentPreview: typeof p.content === 'string'
      ? (p.content.length > 200 ? p.content.slice(0, 200) + '...' : p.content)
      : '',
    updatedAt: p.updatedAt,
  }));
  return [{
    type: 'text',
    text: JSON.stringify(
      withProjectStamp(target, { folders, prompts: trimmed, total: prompts.length }),
      null,
      2,
    ),
  }];
}

async function toolPromptsGet(args) {
  const promptId = String(args.promptId || '');
  if (!promptId) return [{ type: 'text', text: 'promptId is required' }];
  const target = await resolveProjectRef(args.project);
  const data = await apiGet(`/api/project-prompts?projectId=${encodeURIComponent(target.id)}`);
  const prompts = Array.isArray(data && data.prompts) ? data.prompts : [];
  const found = prompts.find((p) => p.id === promptId);
  if (!found) {
    return [{ type: 'text', text: `Prompt ${promptId} not found in project ${target.name || target.id}.` }];
  }
  // Spread, not nested: the current project keeps its historical bare-object
  // shape and only a cross-project read gains the origin stamp.
  return [{ type: 'text', text: JSON.stringify(withProjectStamp(target, found), null, 2) }];
}

async function toolPromptsSave(args) {
  const title = String(args.title || '').trim();
  const content = String(args.content || '');
  if (!title) return [{ type: 'text', text: 'title is required (non-empty string).' }];
  if (!content.trim()) return [{ type: 'text', text: 'content is required (non-empty string).' }];

  // Pull current library, append, push back. The /api/project-prompts endpoint
  // is "PUT-style" (the desktop pushes the full library), so we need a
  // read-modify-write to add a single prompt without dropping siblings.
  const current = await apiGet(`/api/project-prompts?projectId=${encodeURIComponent(PROJECT_ID)}`);
  const folders = Array.isArray(current && current.folders) ? current.folders : [];
  const prompts = Array.isArray(current && current.prompts) ? current.prompts : [];

  const targetFolderId = typeof args.folderId === 'string' ? args.folderId : null;
  const inFolder = prompts.filter((p) => (p.folderId || null) === targetFolderId);
  const now = new Date().toISOString();
  const editedBy = buildAgentEditedBy(args);
  const created = {
    id: nanoidPrompt(),
    folderId: targetFolderId,
    title,
    content,
    tags: Array.isArray(args.tags) ? args.tags.filter((t) => typeof t === 'string') : [],
    position: inFolder.length,
    isPersonal: !!args.isPersonal,
    editedBy,
    contributors: buildContributors(null, editedBy, now),
    createdAt: now,
    updatedAt: now,
  };

  const next = { folders, prompts: [...prompts, created] };
  const pushed = await apiPost('/api/project-prompts', {
    projectId: PROJECT_ID,
    folders: next.folders,
    prompts: next.prompts,
  });
  const pushedFolders = Array.isArray(pushed && pushed.folders) ? pushed.folders : next.folders;
  const pushedPrompts = Array.isArray(pushed && pushed.prompts) ? pushed.prompts : next.prompts;
  refreshPromptsCache(pushedFolders, pushedPrompts, pushed && pushed.updatedAt);
  return [{ type: 'text', text: JSON.stringify({ ok: true, prompt: created }, null, 2) }];
}

async function toolSkillsList(args) {
  const folderId = typeof args.folderId === 'string' ? args.folderId : null;
  const target = await resolveProjectRef(args.project);
  const data = await apiGet(`/api/project-skills?projectId=${encodeURIComponent(target.id)}`);
  const folders = Array.isArray(data && data.folders) ? data.folders : [];
  const skills = Array.isArray(data && data.skills) ? data.skills : [];
  const filtered = folderId === null
    ? skills
    : skills.filter((s) => (s.folderId || null) === folderId);
  const trimmed = filtered.map((s) => ({
    id: s.id,
    name: s.name,
    description: s.description,
    folderId: s.folderId,
    tags: s.tags || [],
    allowedTools: s.allowedTools || [],
    isPersonal: !!s.isPersonal,
    contentPreview: typeof s.content === 'string'
      ? (s.content.length > 200 ? s.content.slice(0, 200) + '...' : s.content)
      : '',
    updatedAt: s.updatedAt,
  }));
  return [{
    type: 'text',
    text: JSON.stringify(
      withProjectStamp(target, { folders, skills: trimmed, total: skills.length }),
      null,
      2,
    ),
  }];
}

async function toolSkillsGet(args) {
  const skillId = String(args.skillId || '');
  if (!skillId) return [{ type: 'text', text: 'skillId is required' }];
  const target = await resolveProjectRef(args.project);
  const data = await apiGet(`/api/project-skills?projectId=${encodeURIComponent(target.id)}`);
  const skills = Array.isArray(data && data.skills) ? data.skills : [];
  const found = skills.find((s) => s.id === skillId);
  if (!found) {
    return [{ type: 'text', text: `Skill ${skillId} not found in project ${target.name || target.id}.` }];
  }
  return [{ type: 'text', text: JSON.stringify(withProjectStamp(target, found), null, 2) }];
}

async function toolSkillsSave(args) {
  const rawName = String(args.name || '').trim();
  const description = String(args.description || '').trim();
  const content = String(args.content || '');
  if (!rawName) return [{ type: 'text', text: 'name is required (non-empty string).' }];
  if (!description) return [{ type: 'text', text: 'description is required (the "when to use" trigger).' }];
  if (!content.trim()) return [{ type: 'text', text: 'content is required (non-empty string).' }];
  const name = slugifySkillName(rawName);

  const current = await apiGet(`/api/project-skills?projectId=${encodeURIComponent(PROJECT_ID)}`);
  const folders = Array.isArray(current && current.folders) ? current.folders : [];
  const skills = Array.isArray(current && current.skills) ? current.skills : [];

  const targetFolderId = typeof args.folderId === 'string' ? args.folderId : undefined;
  const now = new Date().toISOString();
  const editedBy = buildAgentEditedBy(args);

  // UPSERT by name (same semantics as memory_save): an existing skill is
  // updated in place — id, createdAt, position and contributor history are
  // preserved, and only the fields the caller passed replace stored values.
  // Names stay unique either way, so Claude never sees two skills with the
  // same `name` field.
  const existing = skills.find((s) => s.name === name);
  let saved;
  let nextSkills;
  if (existing) {
    saved = {
      ...existing,
      description,
      content,
      folderId: targetFolderId !== undefined ? targetFolderId : (existing.folderId ?? null),
      allowedTools: Array.isArray(args.allowedTools)
        ? args.allowedTools.filter((t) => typeof t === 'string')
        : existing.allowedTools || [],
      tags: Array.isArray(args.tags)
        ? args.tags.filter((t) => typeof t === 'string')
        : existing.tags || [],
      isPersonal: typeof args.isPersonal === 'boolean' ? args.isPersonal : !!existing.isPersonal,
      editedBy,
      contributors: buildContributors(existing, editedBy, now),
      updatedAt: now,
    };
    nextSkills = skills.map((s) => (s.id === existing.id ? saved : s));
  } else {
    const resolvedFolderId = targetFolderId !== undefined ? targetFolderId : null;
    const inFolder = skills.filter((s) => (s.folderId || null) === resolvedFolderId);
    saved = {
      id: nanoidSkill(),
      folderId: resolvedFolderId,
      name,
      description,
      content,
      allowedTools: Array.isArray(args.allowedTools) ? args.allowedTools.filter((t) => typeof t === 'string') : [],
      tags: Array.isArray(args.tags) ? args.tags.filter((t) => typeof t === 'string') : [],
      position: inFolder.length,
      isPersonal: !!args.isPersonal,
      source: 'mcp',
      editedBy,
      contributors: buildContributors(null, editedBy, now),
      createdAt: now,
      updatedAt: now,
    };
    nextSkills = [...skills, saved];
  }

  const next = { folders, skills: nextSkills };
  const pushed = await apiPost('/api/project-skills', {
    projectId: PROJECT_ID,
    folders: next.folders,
    skills: next.skills,
  });
  const pushedFolders = Array.isArray(pushed && pushed.folders) ? pushed.folders : next.folders;
  const pushedSkills = Array.isArray(pushed && pushed.skills) ? pushed.skills : next.skills;
  refreshSkillsCache(pushedFolders, pushedSkills, pushed && pushed.updatedAt);
  return [{ type: 'text', text: JSON.stringify({ ok: true, updated: !!existing, skill: saved }, null, 2) }];
}

// ── Project Memory tools ──────────────────────────────────────────────────
// Wire format: { folders, notes }. The row is shared by every user/agent of
// the project (keyed by the stable PROJECT_ID alone, no per-user scoping).

async function fetchMemory(projectId = PROJECT_ID) {
  const data = await apiGet(`/api/project-memory?projectId=${encodeURIComponent(projectId)}`);
  return {
    folders: Array.isArray(data && data.folders) ? data.folders : [],
    notes: Array.isArray(data && data.notes) ? data.notes : [],
    updatedAt: (data && data.updatedAt) || null,
  };
}

async function pushMemory(folders, notes) {
  const pushed = await apiPost('/api/project-memory', {
    projectId: PROJECT_ID,
    folders,
    notes,
  });
  const pushedFolders = Array.isArray(pushed && pushed.folders) ? pushed.folders : folders;
  const pushedNotes = Array.isArray(pushed && pushed.notes) ? pushed.notes : notes;
  refreshMemoryCache(pushedFolders, pushedNotes, pushed && pushed.updatedAt);
  return { folders: pushedFolders, notes: pushedNotes };
}

// ── Memory taxonomy (v2) ────────────────────────────────────────────────────
//
// Two roots, and only two: `global/<kind>` for knowledge that belongs to no
// single feature, `features/<feature>.md` for everything else. The rule is
// enforced here rather than merely described in the tool prompt, because the
// previous taxonomy change proved that a wording-only contract loses against
// whatever folders already exist in the project.
//
// Keep in sync with desktop/src/services/memoryMigration.ts (the migration
// that reshapes existing projects to this same layout).

const MEMORY_ROOT_GLOBAL = 'global';
const MEMORY_ROOT_FEATURES = 'features';
const MEMORY_GLOBAL_KINDS = [
  'architecture', 'conventions', 'pitfalls', 'decisions', 'bugs', 'components', 'patterns',
];
const MEMORY_MAX_DEPTH = 3;

/** Rewrite any requested folder into a canonical path under global/ or features/. */
function normalizeMemoryFolderPath(raw) {
  const segments = String(raw || '').split('/').map((s) => s.trim()).filter(Boolean);
  if (!segments.length) return [MEMORY_ROOT_FEATURES];
  const head = segments[0].toLowerCase();
  if (head === MEMORY_ROOT_GLOBAL || head === MEMORY_ROOT_FEATURES) {
    return [head, ...segments.slice(1)].slice(0, MEMORY_MAX_DEPTH);
  }
  // A kind of knowledge is cross-cutting: it lives under global/.
  if (MEMORY_GLOBAL_KINDS.includes(head)) {
    return [MEMORY_ROOT_GLOBAL, ...segments].slice(0, MEMORY_MAX_DEPTH);
  }
  // A bare feature name adds no level: the NOTE carries the feature name
  // (features/<feature>.md). A real sub-area keeps its folder.
  if (segments.length === 1) return [MEMORY_ROOT_FEATURES];
  return [MEMORY_ROOT_FEATURES, ...segments].slice(0, MEMORY_MAX_DEPTH);
}

/**
 * Walk the normalized path, creating missing segments under their parent.
 * Case-insensitive matching avoids near-duplicates ("Billing" vs "billing").
 * Returns the (possibly extended) folder list, the target id and the path.
 */
function resolveMemoryFolder(folders, rawFolder, now) {
  const segments = normalizeMemoryFolderPath(rawFolder);

  // A bare feature name that ALREADY has its own folder under features/ (a
  // feature big enough to hold several notes) keeps using it instead of being
  // flattened back to the features root.
  const bare = String(rawFolder || '').trim();
  if (segments.length === 1 && segments[0] === MEMORY_ROOT_FEATURES && bare && !bare.includes('/')) {
    const featuresRoot = folders.find(
      (f) => (f.parentId ?? null) === null && f.name.toLowerCase() === MEMORY_ROOT_FEATURES,
    );
    if (featuresRoot) {
      const child = folders.find(
        (f) => f.parentId === featuresRoot.id && f.name.toLowerCase() === bare.toLowerCase(),
      );
      if (child) segments.push(child.name);
    }
  }

  let next = folders;
  let parentId = null;
  let folderId = null;
  for (const segment of segments) {
    let folder = next.find(
      (f) => (f.parentId ?? null) === parentId && f.name.toLowerCase() === segment.toLowerCase(),
    );
    if (!folder) {
      folder = { id: nanoidSkill(), name: segment, parentId, position: next.length, createdAt: now };
      next = [...next, folder];
    }
    parentId = folder.id;
    folderId = folder.id;
  }
  return { folders: next, folderId, path: segments.join('/') };
}

/** Full folder path of a note ("global/pitfalls"), so agents SEE the hierarchy. */
function memoryFolderPath(folders, folderId) {
  if (!folderId) return null;
  const byId = new Map(folders.map((f) => [f.id, f]));
  const chain = [];
  let current = byId.get(folderId);
  let guard = 0;
  while (current && guard++ < 10) {
    chain.unshift(current.name);
    current = current.parentId ? byId.get(current.parentId) : null;
  }
  return chain.length ? chain.join('/') : null;
}

async function toolMemoryList(args) {
  const folderId = typeof args.folderId === 'string' ? args.folderId : null;
  const target = await resolveProjectRef(args.project);
  const { folders, notes, updatedAt } = await fetchMemory(target.id);
  // This tool is the explicit "give me the fresh truth" path an agent falls
  // back to when the local mirror looks stale, so it doubles as a refresh:
  // the answer it returns and the files on disk end up in the same state.
  // NEVER for another project: the mirror under AR_PROJECT_PATH belongs to THIS
  // project, and materializing a sibling's notes into it would replace this
  // project's memory on disk with someone else's.
  if (target.isCurrent) refreshMemoryCache(folders, notes, updatedAt);
  const filtered = folderId === null
    ? notes
    : notes.filter((n) => (n.folderId || null) === folderId);
  const trimmed = filtered.map((n) => ({
    id: n.id,
    name: n.name,
    description: n.description,
    folder: memoryFolderPath(folders, n.folderId),
    tags: n.tags || [],
    contentPreview: typeof n.content === 'string'
      ? (n.content.length > 200 ? n.content.slice(0, 200) + '...' : n.content)
      : '',
    updatedAt: n.updatedAt,
  }));
  return [{
    type: 'text',
    text: JSON.stringify(
      withProjectStamp(target, { folders, notes: trimmed, total: notes.length }),
      null,
      2,
    ),
  }];
}

async function toolMemoryGet(args) {
  const ref = String(args.note || '').trim();
  if (!ref) return [{ type: 'text', text: 'note is required (id or exact name).' }];
  const target = await resolveProjectRef(args.project);
  const { notes } = await fetchMemory(target.id);
  const found = notes.find((n) => n.id === ref) || notes.find((n) => n.name === ref);
  if (!found) {
    const where = target.isCurrent ? 'the project memory' : `the memory of ${target.name || target.id}`;
    return [{ type: 'text', text: `Note "${ref}" not found in ${where}. Call memory_list${target.isCurrent ? '' : ' with the same `project`'} to see what exists.` }];
  }
  return [{ type: 'text', text: JSON.stringify(withProjectStamp(target, found), null, 2) }];
}

async function toolMemorySave(args) {
  const rawName = String(args.name || '').trim();
  const description = String(args.description || '').trim();
  const content = String(args.content || '');
  if (!rawName) return [{ type: 'text', text: 'name is required (non-empty string).' }];
  if (!description) return [{ type: 'text', text: 'description is required (the retrieval cue).' }];
  if (!content.trim()) return [{ type: 'text', text: 'content is required (non-empty string).' }];
  const name = slugifySkillName(rawName);

  const current = await fetchMemory();
  let folders = current.folders;
  const notes = current.notes;
  const now = new Date().toISOString();

  const existing = notes.find((n) => n.name === name);

  // Resolve the destination. Anything the agent passes is normalized to the
  // canonical two-root layout first (see normalizeMemoryFolderPath), then the
  // path is walked, creating missing segments under their parent. Omitting
  // `folder` keeps an existing note where it is; a NEW note still gets placed
  // (never at the root) rather than landing nowhere.
  const rawFolder = typeof args.folder === 'string' ? args.folder.trim() : '';
  let targetFolderId;
  let resolvedPath;
  if (rawFolder || !existing) {
    const resolved = resolveMemoryFolder(folders, rawFolder, now);
    folders = resolved.folders;
    targetFolderId = resolved.folderId;
    resolvedPath = resolved.path;
  }

  const tags = Array.isArray(args.tags) ? args.tags.filter((t) => typeof t === 'string') : undefined;

  const editedBy = buildAgentEditedBy(args);
  let saved;
  let nextNotes;
  if (existing) {
    // UPDATE OVER CREATE: same name upserts the existing note in place.
    saved = {
      ...existing,
      description,
      content,
      folderId: targetFolderId !== undefined ? targetFolderId : existing.folderId,
      tags: tags !== undefined ? tags : existing.tags || [],
      editedBy,
      contributors: buildContributors(existing, editedBy, now),
      updatedAt: now,
    };
    nextNotes = notes.map((n) => (n.id === existing.id ? saved : n));
  } else {
    const resolvedFolderId = targetFolderId !== undefined ? targetFolderId : null;
    const inFolder = notes.filter((n) => (n.folderId || null) === resolvedFolderId);
    saved = {
      id: nanoidSkill(),
      folderId: resolvedFolderId,
      name,
      description,
      content,
      tags: tags || [],
      position: inFolder.length,
      isPersonal: false,
      source: 'mcp',
      editedBy,
      contributors: buildContributors(null, editedBy, now),
      createdAt: now,
      updatedAt: now,
    };
    nextNotes = [...notes, saved];
  }

  await pushMemory(folders, nextNotes);
  // Echo the resolved path: when the requested folder was rewritten to the
  // canonical layout, this is how the agent learns where notes actually go.
  return [{
    type: 'text',
    text: JSON.stringify(
      { ok: true, updated: !!existing, folder: resolvedPath ?? null, note: saved },
      null,
      2,
    ),
  }];
}

// Account-level commands (available in every project). Absent / unreachable
// endpoint is not an error: the user may simply have none.
async function fetchGlobalCommands() {
  try {
    const data = await apiGet('/api/global-commands');
    const commands = Array.isArray(data && data.commands) ? data.commands : [];
    return commands.map((c) => ({ ...c, scope: 'global' }));
  } catch (_) {
    return [];
  }
}

async function fetchAllCommands() {
  const data = await apiGet(`/api/project-commands?projectId=${encodeURIComponent(PROJECT_ID)}`);
  const folders = Array.isArray(data && data.folders) ? data.folders : [];
  const projectCommands = (Array.isArray(data && data.commands) ? data.commands : [])
    .map((c) => ({ ...c, scope: 'project' }));
  const globalCommands = await fetchGlobalCommands();
  return { folders, commands: [...projectCommands, ...globalCommands] };
}

async function toolCommandsList() {
  const { folders, commands } = await fetchAllCommands();
  return [{ type: 'text', text: JSON.stringify({ folders, commands, total: commands.length }, null, 2) }];
}

async function toolCommandsGet(args) {
  const commandId = String(args.commandId || '');
  if (!commandId) return [{ type: 'text', text: 'commandId is required' }];
  const { commands } = await fetchAllCommands();
  const found = commands.find((c) => c.id === commandId);
  if (!found) return [{ type: 'text', text: `Command ${commandId} not found (project or global).` }];
  return [{ type: 'text', text: JSON.stringify(found, null, 2) }];
}

// ── SSH ────────────────────────────────────────────────────────────────────
//
// Served by the desktop bridge, not the cloud API: the connection list lives on
// the user's machine and must work logged out. Secrets never cross this
// boundary — `ssh:list` returns metadata and `ssh:connect` only names an id.

async function toolSshList() {
  const result = await bridgeCall('ssh:list', { projectId: PROJECT_ID });
  const connections = (result && result.connections) || [];
  return [{
    type: 'text',
    text: JSON.stringify({
      connections,
      total: connections.length,
      note: 'Credentials are never exposed: the desktop injects them into the terminal itself.',
    }, null, 2),
  }];
}

async function toolSshConnect(args) {
  const connectionId = String(args.connectionId || '');
  if (!connectionId) return [{ type: 'text', text: 'connectionId is required' }];
  const result = await bridgeCall('ssh:connect', {
    projectPath: PROJECT_PATH,
    connectionId,
  });
  if (!result || !result.ok) {
    return [{ type: 'text', text: `SSH connect failed: ${(result && result.error) || 'unknown error'}` }];
  }
  return [{
    type: 'text',
    text: JSON.stringify({
      ok: true,
      connectionId,
      message: 'SSH terminal tab is now open in the desktop Commands panel.',
    }, null, 2),
  }];
}

async function toolCommandsRun(args) {
  const commandId = String(args.commandId || '');
  if (!commandId) return [{ type: 'text', text: 'commandId is required' }];
  const result = await bridgeCall('commands:run', {
    projectPath: PROJECT_PATH,
    commandId,
  });
  if (!result || !result.ok) {
    return [{ type: 'text', text: `Launch failed: ${(result && result.error) || 'unknown error'}` }];
  }
  return [{
    type: 'text',
    text: JSON.stringify({
      ok: true,
      ptyId: result.ptyId,
      command: result.command,
      cwd: result.cwd,
      message: 'Terminal tab is now open in the Dev Commands panel.',
    }, null, 2),
  }];
}

async function toolCommandsCreate(args) {
  const name = String(args.name || '').trim();
  const command = String(args.command || '').trim();
  if (!name) return [{ type: 'text', text: 'name is required (non-empty string).' }];
  if (!command) return [{ type: 'text', text: 'command is required (non-empty string).' }];

  // Global scope = the user account library, offered in every project.
  const isGlobal = args.scope === 'global';
  const endpoint = isGlobal
    ? '/api/global-commands'
    : `/api/project-commands?projectId=${encodeURIComponent(PROJECT_ID)}`;

  const current = await apiGet(endpoint);
  const folders = Array.isArray(current && current.folders) ? current.folders : [];
  const commands = Array.isArray(current && current.commands) ? current.commands : [];

  const created = {
    id: nanoidCmd(),
    name,
    command,
    cwd: typeof args.cwd === 'string' && args.cwd.trim() ? args.cwd : '.',
    kind: args.kind === 'snippet' ? 'snippet' : 'process',
    category: typeof args.category === 'string' ? args.category : 'other',
    // A global command has no project folder tree to belong to.
    folderId: !isGlobal && typeof args.folderId === 'string' ? args.folderId : null,
    autoExec: args.autoExec === false ? false : true,
    position: commands.length,
    isPersonal: !isGlobal && !!args.isPersonal,
    ...(isGlobal ? { isGlobal: true } : {}),
    createdAt: new Date().toISOString(),
  };

  const next = { folders, commands: [...commands, created] };
  const pushed = isGlobal
    ? await apiPost('/api/global-commands', { folders: next.folders, commands: next.commands })
    : await apiPost('/api/project-commands', {
        projectId: PROJECT_ID,
        folders: next.folders,
        commands: next.commands,
      });
  const pushedFolders = Array.isArray(pushed && pushed.folders) ? pushed.folders : next.folders;
  const pushedCommands = Array.isArray(pushed && pushed.commands) ? pushed.commands : next.commands;
  // Keep the on-disk mirrors fresh so the desktop (and `commands_run`, which
  // resolves ids from disk) sees the new command right away.
  if (isGlobal) {
    refreshGlobalCommandsCache(pushedFolders, pushedCommands);
  } else {
    refreshCommandsCache(pushedFolders, pushedCommands, pushed && pushed.updatedAt);
  }
  return [{
    type: 'text',
    text: JSON.stringify({ ok: true, scope: isGlobal ? 'global' : 'project', command: created }, null, 2),
  }];
}

// ── Dispatcher ────────────────────────────────────────────────────────────

async function handleToolCall(name, args) {
  const a = args || {};
  // `project` is a READ-ONLY selector. JSON-Schema would silently drop it on a
  // tool that does not declare it, and the call would then hit the CURRENT
  // project: a ticket meant for the backend repo would land here, orphaned and
  // invisible to whoever asked for it. Refusing is the only safe answer.
  if (typeof a.project === 'string' && a.project.trim() && !CROSS_PROJECT_READ_TOOLS.has(name)) {
    const target = await resolveProjectRef(a.project);
    if (!target.isCurrent) {
      throw new Error(
        `${name} does not accept \`project\`: writing into another project (or running anything there) is not supported. This call would have silently acted on the CURRENT project instead of "${target.name || target.id}". Drop the argument, or ask the user to open that project. Cross-project support is read-only: ${[...CROSS_PROJECT_READ_TOOLS].join(', ')}.`,
      );
    }
  }
  switch (name) {
    case 'projects_list':    return toolProjectsList();
    case 'backlog_list':     return toolBacklogList(a);
    case 'backlog_get':      return toolBacklogGet(a);
    case 'backlog_create':   return toolBacklogCreate(a);
    case 'backlog_update':   return toolBacklogUpdate(a);
    case 'backlog_delete':   return toolBacklogDelete(a);
    case 'backlog_reorder':  return toolBacklogReorder(a);
    case 'backlog_start_next': return toolBacklogStartNext(a);
    case 'backlog_publish_mockup': return toolBacklogPublishMockup(a);
    case 'backlog_comment':  return toolBacklogComment(a);
    case 'prompts_list':     return toolPromptsList(a);
    case 'prompts_get':      return toolPromptsGet(a);
    case 'prompts_save':     return toolPromptsSave(a);
    case 'skills_list':      return toolSkillsList(a);
    case 'skills_get':       return toolSkillsGet(a);
    case 'skills_save':      return toolSkillsSave(a);
    case 'teams_list':       return teamTools.toolTeamsList(a);
    case 'teams_get':        return teamTools.toolTeamsGet(a);
    case 'teams_save':       return teamTools.toolTeamsSave(a);
    case 'teams_delete':     return teamTools.toolTeamsDelete(a);
    case 'agents_list':      return teamTools.toolAgentsList(a);
    case 'agents_get':       return teamTools.toolAgentsGet(a);
    case 'agents_spawn':     return toolAgentsSpawn(a);
    case 'memory_list':      return toolMemoryList(a);
    case 'memory_get':       return toolMemoryGet(a);
    case 'memory_save':      return toolMemorySave(a);
    case 'commands_list':    return toolCommandsList();
    case 'commands_get':     return toolCommandsGet(a);
    case 'commands_create':  return toolCommandsCreate(a);
    case 'commands_run':     return toolCommandsRun(a);
    case 'ssh_list':         return toolSshList();
    case 'ssh_connect':      return toolSshConnect(a);
    case 'settings_list':    return settingsTools.toolSettingsList(a);
    case 'settings_get':     return settingsTools.toolSettingsGet(a);
    case 'settings_set':     return settingsTools.toolSettingsSet(a);
    default: throw new Error(`Unknown tool: ${name}`);
  }
}

// ── JSON-RPC plumbing ─────────────────────────────────────────────────────

function send(msg) {
  process.stdout.write(JSON.stringify(msg) + '\n');
}

function sendResult(id, result) { send({ jsonrpc: '2.0', id, result }); }
function sendError(id, code, message) {
  send({ jsonrpc: '2.0', id, error: { code, message } });
}

const rl = readline.createInterface({ input: process.stdin });
rl.on('line', async (line) => {
  let msg;
  try { msg = JSON.parse(line); }
  catch (_) { return; }
  if (!msg || msg.jsonrpc !== '2.0') return;

  // Notifications (no id) are fire-and-forget; MCP uses them for initialized.
  if (msg.id === undefined) return;

  try {
    if (msg.method === 'initialize') {
      sendResult(msg.id, {
        protocolVersion: '2024-11-05',
        capabilities: { tools: {} },
        serverInfo: { name: 'AgentsRoom-MCP', version: SERVER_VERSION },
      });
      return;
    }
    if (msg.method === 'tools/list') {
      sendResult(msg.id, { tools: TOOLS });
      return;
    }
    if (msg.method === 'tools/call') {
      const params = msg.params || {};
      try {
        const content = await handleToolCall(params.name, params.arguments);
        sendResult(msg.id, { content, isError: false });
      } catch (err) {
        sendResult(msg.id, {
          content: [{ type: 'text', text: String((err && err.message) || err) }],
          isError: true,
        });
      }
      return;
    }
    if (msg.method === 'ping') {
      sendResult(msg.id, {});
      return;
    }
    sendError(msg.id, -32601, 'Method not found: ' + msg.method);
  } catch (err) {
    sendError(msg.id, -32603, String((err && err.message) || err));
  }
});

process.on('SIGTERM', () => process.exit(0));
process.on('SIGINT', () => process.exit(0));
