#!/usr/bin/env node
/**
 * AgentsRoom Team MCP server (stdio).
 *
 * Spawned by Claude Code (or any MCP-capable agent CLI) through the project's
 * .mcp.json when the agent is part of a team run. Speaks JSON-RPC 2.0 framed
 * as line-delimited JSON over stdin/stdout. Exposes tools that let the agent:
 *
 *   - Read the current team context + the inbound handoff payload
 *     (team_get_context, team_read_diff)
 *   - Read what other agents in the run already said / asked / did
 *     (team_read_notes, team_read_timeline, team_read_inbox)
 *   - Write durable messages to its teammates
 *     (team_post_note, team_ask)
 *   - Signal step completion with a structured, VALIDATED payload
 *     (team_complete_step)
 *
 * Tools are the authoritative communication channel. The session-file
 * annotation pattern (agent writing .agentsroom/sessions/{id}.json#team) is
 * kept as a fallback but is no longer required — new system prompts direct
 * agents to use these tools.
 *
 * Why a dedicated MCP server (vs. injecting the payload in the system prompt)
 * -----------------------------------------------------------------------------
 * Tool schemas are re-sent to the model at every turn, so they survive
 * context compaction. Instructions buried in a long system prompt are the
 * first thing the model forgets after 30+ turns. A tool, by contrast, is
 * always advertised and always callable, no matter how long the run goes.
 *
 * Required env vars
 * -----------------
 *   AGENTSROOM_TEAM_RUN_ID    : the run id
 *   AGENTSROOM_TEAM_NODE_ID   : node id at SPAWN TIME (fallback only, see below)
 *   AGENTSROOM_TEAM_AGENT_ID  : the ephemeral agent id spawned by the run
 *   AGENTSROOM_TEAM_WORKSPACE : absolute workspace cwd (project root or worktree)
 *   AGENTSROOM_TEAM_ROLE      : role at SPAWN TIME (fallback only, see below)
 *
 * Single-agent morphing (v3)
 * --------------------------
 * A team run drives ONE agent whose role changes at each step ("you are now
 * the QA agent"). The PTY env vars are frozen at spawn, so NODE_ID / ROLE
 * only describe the FIRST step. The current node/role are read dynamically
 * from state.json (currentStep), written by the main process on every
 * transition. Env values are kept as a fallback for a missing/corrupt state
 * file only.
 *
 * Zero dependencies — shipped as-is inside the packaged Electron app and
 * invoked via `node /path/to/team-mcp-server.cjs` by the agent. Keep this
 * file dependency-free and ES5-friendly so it runs on any user's Node.
 */

'use strict';

const fs = require('node:fs');
const path = require('node:path');
const readline = require('node:readline');
const { execSync } = require('node:child_process');
const { installStdioGuards } = require('./mcp-stdio-runtime.cjs');

const SERVER_VERSION = '1.1.0';

// Never die on a stray async error: losing this server mid-run strips the
// team tools from the agent, and a step that cannot call `team_complete_step`
// stalls the whole run with no visible cause.
installStdioGuards('agentsroom-team');

// ── Env resolution + graceful self-disable ────────────────────────────────

const RUN_ID = process.env.AGENTSROOM_TEAM_RUN_ID;
const NODE_ID = process.env.AGENTSROOM_TEAM_NODE_ID;
const AGENT_ID = process.env.AGENTSROOM_TEAM_AGENT_ID;
const WORKSPACE = process.env.AGENTSROOM_TEAM_WORKSPACE;
const ROLE = process.env.AGENTSROOM_TEAM_ROLE || 'agent';

// When the AGENTSROOM_TEAM_* env vars are absent, this is NOT a team-run
// session: the `agentsroom-team` entry in the project's .mcp.json outlives
// the run (and can even be copied verbatim into another CLI's config, e.g.
// Codex's "import from Claude Code" migration). Hard-failing here
// (process.exit(1), the old behaviour) made every such session report a
// scary MCP startup error ("handshaking with MCP server failed: connection
// closed: initialize response"). Instead we mirror the team-reminder hook:
// complete the handshake, advertise ZERO tools, and explain once on stderr.
// Real team agents are unaffected — their PTY carries the env.
const TEAM_ENV_OK = Boolean(RUN_ID && NODE_ID && AGENT_ID && WORKSPACE);
if (!TEAM_ENV_OK) {
  // eslint-disable-next-line no-console
  console.error(
    '[agentsroom-team-mcp] AGENTSROOM_TEAM_* env vars absent — not a team-run session, serving no tools.',
  );
}

// ── Path helpers (must match teamRunState.ts exactly) ─────────────────────
// Empty when self-disabled: no tool is reachable in that mode, so the paths
// are never used (tools/list returns [] and tools/call is rejected).

const RUN_DIR = TEAM_ENV_OK ? path.join(WORKSPACE, '.agentsroom', 'team-runs', RUN_ID) : '';
const STATE_FILE = path.join(RUN_DIR, 'state.json');
const NOTES_FILE = path.join(RUN_DIR, 'NOTES.md');
const INBOX_DIR = path.join(RUN_DIR, 'inbox');
const TIMELINE_FILE = path.join(RUN_DIR, 'timeline.jsonl');
const STEP_COMPLETE_FILE = path.join(RUN_DIR, 'step-complete.json');

// ── Atomic I/O helpers ────────────────────────────────────────────────────

function atomicWriteJson(destPath, obj) {
  fs.mkdirSync(path.dirname(destPath), { recursive: true });
  const tmp = `${destPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
  const json = JSON.stringify(obj, null, 2);
  try {
    const fd = fs.openSync(tmp, 'w', 0o644);
    try {
      fs.writeFileSync(fd, json, 'utf-8');
      try { fs.fsyncSync(fd); } catch (_) { /* optional */ }
    } finally {
      fs.closeSync(fd);
    }
    fs.renameSync(tmp, destPath);
  } catch (err) {
    try { fs.unlinkSync(tmp); } catch (_) { /* best-effort */ }
    throw err;
  }
}

function readJsonSafe(filePath) {
  try {
    const raw = fs.readFileSync(filePath, 'utf-8');
    return JSON.parse(raw);
  } catch (_) {
    return null;
  }
}

function readStateNow() {
  return readJsonSafe(STATE_FILE);
}

// ── Dynamic step resolution (single-agent morphing + parallel branches) ───
//
// The run's PRIMARY agent changes role at every step, but the PTY env vars
// are frozen at spawn. state.json#currentStep — written by main on every
// node transition — is the source of truth for "which node/role am I right
// now". Env values are only used when the state file is unreadable.
//
// Parallel fan-out: branch agents are spawned fresh for ONE branch and never
// morph, so their identity lives in state.json#parallelSteps keyed by their
// own AGENT_ID (env-reliable). That entry — when present — takes precedence
// over currentStep, which tracks the primary agent's branch.

function myParallelStep(state) {
  const s = state === undefined ? readStateNow() : state;
  if (s && s.parallelSteps && s.parallelSteps[AGENT_ID]
      && typeof s.parallelSteps[AGENT_ID].nodeId === 'string') {
    return s.parallelSteps[AGENT_ID];
  }
  return null;
}

function stepNodeId(state) {
  const s = state === undefined ? readStateNow() : state;
  const branch = myParallelStep(s);
  if (branch) return branch.nodeId;
  if (s && s.currentStep && typeof s.currentStep.nodeId === 'string' && s.currentStep.nodeId) {
    return s.currentStep.nodeId;
  }
  return NODE_ID;
}

function stepRole(state) {
  const s = state === undefined ? readStateNow() : state;
  const branch = myParallelStep(s);
  if (branch && typeof branch.role === 'string' && branch.role) return branch.role;
  if (s && s.currentStep && typeof s.currentStep.role === 'string' && s.currentStep.role) {
    return s.currentStep.role;
  }
  return ROLE;
}

// ── Validation ────────────────────────────────────────────────────────────

const MIN_SUMMARY_CHARS = 40;
const MAX_SUMMARY_CHARS = 1500;
const MAX_LIST_ITEMS = 20;
const MAX_LIST_ITEM_CHARS = 300;
const MAX_FLAG_KEYS = 20;
const MAX_NOTE_CHARS = 6000;
const MAX_QUESTION_CHARS = 1500;
const DIFF_MAX_BYTES = 60_000;
/**
 * Wall-clock ceiling for the `git` calls behind `team_read_diff`. This process
 * is a single-threaded stdio server: a git command that blocks (index.lock
 * held by another agent, a credential or GPG prompt, a hung filter driver)
 * takes every other tool call down with it, and the agent sees a tool that
 * never returns.
 */
const GIT_TIMEOUT_MS = 20_000;

function validateCompleteStepPayload(input) {
  const errors = [];
  if (!input || typeof input !== 'object') {
    return { ok: false, errors: ['payload must be an object'] };
  }
  const summary = input.featureSummary;
  if (typeof summary !== 'string') {
    errors.push('featureSummary: required string');
  } else if (summary.trim().length < MIN_SUMMARY_CHARS) {
    errors.push(
      `featureSummary: too short (${summary.trim().length} chars). Write at least ${MIN_SUMMARY_CHARS} chars explaining what you did and why.`,
    );
  } else if (summary.length > MAX_SUMMARY_CHARS) {
    errors.push(`featureSummary: too long (max ${MAX_SUMMARY_CHARS} chars)`);
  }
  if (input.risks !== undefined) {
    if (!Array.isArray(input.risks)) {
      errors.push('risks: must be an array of strings');
    } else if (input.risks.length > MAX_LIST_ITEMS) {
      errors.push(`risks: too many items (max ${MAX_LIST_ITEMS})`);
    } else {
      for (let i = 0; i < input.risks.length; i++) {
        const r = input.risks[i];
        if (typeof r !== 'string' || r.length > MAX_LIST_ITEM_CHARS) {
          errors.push(`risks[${i}]: must be a string up to ${MAX_LIST_ITEM_CHARS} chars`);
        }
      }
    }
  }
  if (input.testHints !== undefined) {
    if (!Array.isArray(input.testHints)) {
      errors.push('testHints: must be an array of strings');
    } else if (input.testHints.length > MAX_LIST_ITEMS) {
      errors.push(`testHints: too many items (max ${MAX_LIST_ITEMS})`);
    } else {
      for (let i = 0; i < input.testHints.length; i++) {
        const h = input.testHints[i];
        if (typeof h !== 'string' || h.length > MAX_LIST_ITEM_CHARS) {
          errors.push(`testHints[${i}]: must be a string up to ${MAX_LIST_ITEM_CHARS} chars`);
        }
      }
    }
  }
  if (input.flags !== undefined) {
    if (!input.flags || typeof input.flags !== 'object' || Array.isArray(input.flags)) {
      errors.push('flags: must be an object of boolean/string/number values');
    } else {
      const keys = Object.keys(input.flags);
      if (keys.length > MAX_FLAG_KEYS) {
        errors.push(`flags: too many keys (max ${MAX_FLAG_KEYS})`);
      }
      for (const k of keys) {
        const v = input.flags[k];
        if (typeof v !== 'boolean' && typeof v !== 'string' && typeof v !== 'number') {
          errors.push(`flags.${k}: must be boolean, string or number`);
        }
      }
    }
  }
  if (input.nextNodeHint !== undefined && typeof input.nextNodeHint !== 'string') {
    errors.push('nextNodeHint: must be a string');
  }
  return errors.length === 0 ? { ok: true } : { ok: false, errors };
}

// ── Tools ─────────────────────────────────────────────────────────────────

const TOOLS = [
  {
    name: 'team_get_context',
    description:
      'Return the current team step context: role, cycle, inbound handoff payload from the previous agent (featureSummary, risks, testHints, flags), and step-specific instructions. Cheap to call — use it at the start of every new turn if you are unsure where you stand, instead of relying on the system prompt which may have been compacted.',
    inputSchema: { type: 'object', properties: {} },
  },
  {
    name: 'team_read_notes',
    description:
      'Return the shared NOTES.md content of the current team run. All agents in the run read/write this file; it is the durable scratchpad. Call this at the start of your step (especially on a re-entry / loop) to catch up on what teammates decided.',
    inputSchema: {
      type: 'object',
      properties: {
        maxBytes: {
          type: 'number',
          description: 'Truncate from the end if the file is larger (default 20000).',
        },
      },
    },
  },
  {
    name: 'team_post_note',
    description:
      'Append a short markdown note to the shared NOTES.md for the team run. Use it to record decisions other agents need to know (e.g. "left a TODO in foo.ts because bar — next step should X"), open questions, or assumptions. Append-only; cannot edit or delete past notes.',
    inputSchema: {
      type: 'object',
      properties: {
        content: { type: 'string', description: 'Markdown body, <= 6000 chars.' },
      },
      required: ['content'],
    },
  },
  {
    name: 'team_read_timeline',
    description:
      'Return the ordered event log of the team run (run-started, node-entered, handoff, run-ended). Useful on loop re-entries to see your own past cycles and what flags were set.',
    inputSchema: {
      type: 'object',
      properties: {
        limit: { type: 'number', description: 'Keep only the last N events (default all).' },
      },
    },
  },
  {
    name: 'team_read_inbox',
    description:
      'Return messages addressed to your role (toRole === your role) or to "any". Messages older than your last read are NOT automatically cleared — callers are expected to track their own cursor if needed. Default returns everything.',
    inputSchema: {
      type: 'object',
      properties: {
        sinceSeq: {
          type: 'number',
          description: 'Skip messages with seq < this value (use 0 for all).',
        },
      },
    },
  },
  {
    name: 'team_ask',
    description:
      'Leave a question or request in the inbox for another role in the team (or "any"). The target agent will see it when they next call team_read_inbox, or be reminded of it via the team reminder hook. Use when you need a decision/clarification but cannot block your own progress.',
    inputSchema: {
      type: 'object',
      properties: {
        toRole: {
          type: 'string',
          description: 'Target role: "dev", "qa", "security", … or "any" for broadcast.',
        },
        question: { type: 'string', description: 'Your question, <= 1500 chars.' },
      },
      required: ['toRole', 'question'],
    },
  },
  {
    name: 'team_read_diff',
    description:
      'Return the git diff of the workspace against the baseline recorded at run start, plus any new untracked files. Capped at 60KB. Use when you need to see exact code changes made by a previous agent instead of re-parsing through a summary.',
    inputSchema: {
      type: 'object',
      properties: {
        stepOnly: {
          type: 'boolean',
          description:
            'If true, diff against the start of the CURRENT step rather than the run start. Default false.',
        },
      },
    },
  },
  {
    name: 'team_complete_step',
    description:
      'Signal that you are done with your step. REQUIRED to trigger the next node in the team run. Pass featureSummary (what you did + why, >= 40 chars), optional risks, testHints, flags (used by conditional edges — e.g. {qaPassed: false} to loop back to dev). On validation failure the tool returns a structured error describing what to fix; fix and call again.',
    inputSchema: {
      type: 'object',
      properties: {
        featureSummary: { type: 'string' },
        risks: { type: 'array', items: { type: 'string' } },
        testHints: { type: 'array', items: { type: 'string' } },
        flags: { type: 'object', additionalProperties: true },
        nextNodeHint: { type: 'string' },
      },
      required: ['featureSummary'],
    },
  },
];

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

function toolGetContext() {
  const state = readStateNow();
  if (!state) {
    return [{ type: 'text', text: 'Team run state not found on disk — run may have been cleaned up.' }];
  }
  const summary = {
    runId: state.runId,
    teamName: state.teamName,
    status: state.status,
    role: stepRole(state),
    nodeId: stepNodeId(state),
    agentId: AGENT_ID,
    currentStep: state.currentStep,
    cycleCount: state.cycleCount,
    noteCount: state.noteCount,
    inboxCount: state.inboxCount,
  };
  return [{ type: 'text', text: JSON.stringify(summary, null, 2) }];
}

function toolReadNotes(args) {
  const maxBytes = typeof args.maxBytes === 'number' ? args.maxBytes : 20_000;
  let content = '';
  try { content = fs.readFileSync(NOTES_FILE, 'utf-8'); } catch (_) { content = ''; }
  if (content.length > maxBytes) {
    content = '…[truncated head]…\n' + content.slice(content.length - maxBytes);
  }
  if (!content.trim()) {
    return [{ type: 'text', text: '(no notes yet — you are the first agent to reach this point)' }];
  }
  return [{ type: 'text', text: content }];
}

function toolPostNote(args) {
  const content = typeof args.content === 'string' ? args.content : '';
  if (!content.trim()) {
    return { error: 'content: required non-empty string' };
  }
  if (content.length > MAX_NOTE_CHARS) {
    return { error: `content: too long (max ${MAX_NOTE_CHARS} chars)` };
  }
  const state = readStateNow();
  if (!state) return { error: 'Team run state not found on disk' };
  const nodeId = stepNodeId(state);
  const role = stepRole(state);
  const cycle = (state.cycleCount && state.cycleCount[nodeId]) || 1;

  fs.mkdirSync(RUN_DIR, { recursive: true });
  const header = `\n## [${role} @ ${new Date().toISOString()}] node=${nodeId} cycle=${cycle}\n`;
  const body = content.trim() + '\n';
  const fd = fs.openSync(NOTES_FILE, 'a');
  try { fs.writeSync(fd, header + body); }
  finally { fs.closeSync(fd); }

  // Bump the note counter in state.json so the UserPromptSubmit hook of other
  // agents can detect "new notes since my last turn" without scanning NOTES.md.
  // The MCP server is a reader of state.json everywhere else; here we perform
  // a scoped read-modify-write that only touches noteCount. We accept a tiny
  // theoretical race with main (e.g. main transitioning status) by using an
  // atomic rewrite of the full object — our copy may be slightly stale on the
  // edge but the main process is the source of truth for high-level fields
  // and will overwrite again on its next transition.
  try {
    const fresh = readStateNow();
    if (fresh) {
      const next = Object.assign({}, fresh, {
        noteCount: (fresh.noteCount || 0) + 1,
      });
      atomicWriteJson(STATE_FILE, next);
    }
  } catch (_) { /* non-critical */ }

  return { ok: true, text: 'Note posted.' };
}

function toolReadTimeline(args) {
  const limit = typeof args.limit === 'number' ? args.limit : Infinity;
  let raw;
  try { raw = fs.readFileSync(TIMELINE_FILE, 'utf-8'); } catch (_) { return [{ type: 'text', text: '[]' }]; }
  const events = [];
  const lines = raw.split('\n');
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    if (!line.trim()) continue;
    try { events.push(JSON.parse(line)); } catch (_) { /* skip */ }
  }
  const out = Number.isFinite(limit) ? events.slice(-limit) : events;
  return [{ type: 'text', text: JSON.stringify(out, null, 2) }];
}

function toolReadInbox(args) {
  const sinceSeq = typeof args.sinceSeq === 'number' ? args.sinceSeq : 0;
  const role = stepRole();
  let entries = [];
  try { entries = fs.readdirSync(INBOX_DIR); } catch (_) { /* empty */ }
  const files = entries.filter((f) => f.endsWith('.json')).sort();
  const out = [];
  for (const f of files) {
    const parsed = readJsonSafe(path.join(INBOX_DIR, f));
    if (!parsed || typeof parsed.seq !== 'number') continue;
    if (parsed.seq < sinceSeq) continue;
    // Filter to messages addressed to this role (or broadcast). Agents can
    // still read the whole inbox manually by passing sinceSeq: 0 — the
    // filtering here keeps the default useful.
    if (parsed.toRole !== role && parsed.toRole !== 'any' && parsed.toRole !== '*') continue;
    out.push(parsed);
  }
  return [{ type: 'text', text: JSON.stringify(out, null, 2) }];
}

function toolAsk(args) {
  const toRole = typeof args.toRole === 'string' ? args.toRole.trim() : '';
  const question = typeof args.question === 'string' ? args.question : '';
  if (!toRole) return { error: 'toRole: required' };
  if (!question.trim()) return { error: 'question: required non-empty string' };
  if (question.length > MAX_QUESTION_CHARS) {
    return { error: `question: too long (max ${MAX_QUESTION_CHARS} chars)` };
  }

  const state = readStateNow();
  if (!state) return { error: 'Team run state not found on disk' };
  const fromRole = stepRole(state);
  const fromNodeId = stepNodeId(state);

  const nextSeq = (state.inboxCount || 0) + 1;
  fs.mkdirSync(INBOX_DIR, { recursive: true });

  const safeFrom = fromRole.replace(/[^a-zA-Z0-9_-]/g, '_');
  const safeTo = toRole.replace(/[^a-zA-Z0-9_-]/g, '_');
  const filename = `${String(nextSeq).padStart(6, '0')}-${safeFrom}-to-${safeTo}.json`;
  const filePath = path.join(INBOX_DIR, filename);
  const msg = {
    seq: nextSeq,
    at: new Date().toISOString(),
    fromRole,
    fromNodeId,
    toRole,
    question,
  };
  atomicWriteJson(filePath, msg);

  try {
    const fresh = readStateNow();
    if (fresh) {
      const next = Object.assign({}, fresh, { inboxCount: nextSeq });
      atomicWriteJson(STATE_FILE, next);
    }
  } catch (_) { /* non-critical */ }

  return { ok: true, text: `Question posted to ${toRole} (#${nextSeq}).` };
}

function toolReadDiff(args) {
  const stepOnly = args && args.stepOnly === true;
  const state = readStateNow();
  // Baseline sha is stored on the state by main; falls back to HEAD^ if missing.
  let baseline = state && state.baselineSha ? state.baselineSha : '';
  if (stepOnly) {
    const branch = myParallelStep(state);
    if (branch && branch.baselineSha) {
      baseline = branch.baselineSha;
    } else if (state && state.currentStep && state.currentStep.baselineSha) {
      baseline = state.currentStep.baselineSha;
    }
  }
  if (!baseline) {
    return [{ type: 'text', text: '(no baseline sha recorded — diff unavailable)' }];
  }

  let out = '';
  try {
    // `timeout` is not optional here: git can block indefinitely on an
    // `index.lock` held by another agent, on a credential/GPG prompt, or on a
    // hung textconv filter. Without it the whole server stops answering — this
    // is a single-threaded stdio process, so one stuck execSync freezes every
    // other tool call too.
    const buf = execSync(`git diff ${baseline} -- .`, {
      cwd: WORKSPACE,
      maxBuffer: DIFF_MAX_BYTES * 2,
      timeout: GIT_TIMEOUT_MS,
    });
    out = buf.toString('utf-8');
  } catch (err) {
    return [{ type: 'text', text: `git diff failed: ${(err && err.message) || err}` }];
  }

  // Include untracked files too, formatted as a pseudo-diff so the agent can
  // see content of newly-created files (git diff skips them by default).
  try {
    const untrackedRaw = execSync('git ls-files --others --exclude-standard', {
      cwd: WORKSPACE,
      encoding: 'utf-8',
      maxBuffer: DIFF_MAX_BYTES * 2,
      timeout: GIT_TIMEOUT_MS,
    });
    const untracked = untrackedRaw.split('\n').filter(Boolean);
    for (const rel of untracked) {
      if (out.length > DIFF_MAX_BYTES) break;
      try {
        const abs = path.join(WORKSPACE, rel);
        // Size-check BEFORE reading. The `out.length` guard above only bounds
        // what we have already accumulated: a single huge untracked artifact
        // (a dump, a log, a stray vendor dir) would still be read whole, then
        // split + mapped + joined — four copies in memory, i.e. an OOM kill,
        // which the CLI reports as the MCP server disconnecting.
        if (fs.statSync(abs).size > DIFF_MAX_BYTES) continue;
        const content = fs.readFileSync(abs, 'utf-8');
        const lines = content.split('\n');
        out += `\n--- /dev/null\n+++ b/${rel}\n@@ -0,0 +1,${lines.length} @@\n`;
        out += lines.map((l) => '+' + l).join('\n') + '\n';
      } catch (_) { /* binary or unreadable */ }
    }
  } catch (_) { /* ignore */ }

  if (out.length > DIFF_MAX_BYTES) {
    out = out.slice(0, DIFF_MAX_BYTES) + '\n…[truncated]';
  }
  if (!out.trim()) {
    return [{ type: 'text', text: '(no diff — workspace matches baseline)' }];
  }
  return [{ type: 'text', text: out }];
}

function toolCompleteStep(args) {
  const v = validateCompleteStepPayload(args);
  if (!v.ok) {
    // Structured error — Claude will see it and retry with a corrected call.
    return {
      error:
        'Validation failed. Fix the following and call team_complete_step again:\n  - ' +
        v.errors.join('\n  - '),
    };
  }
  const state = readStateNow();
  if (!state) return { error: 'Team run state not found on disk' };
  const nodeId = stepNodeId(state);
  const cycle = (state.cycleCount && state.cycleCount[nodeId]) || 1;

  const signal = {
    runId: RUN_ID,
    nodeId,
    agentId: AGENT_ID,
    cycle,
    writtenAt: new Date().toISOString(),
    payload: {
      featureSummary: args.featureSummary,
      risks: Array.isArray(args.risks) ? args.risks : [],
      testHints: Array.isArray(args.testHints) ? args.testHints : [],
      flags: args.flags && typeof args.flags === 'object' ? args.flags : {},
    },
  };
  if (typeof args.nextNodeHint === 'string' && args.nextNodeHint.trim()) {
    signal.payload.nextNodeHint = args.nextNodeHint.trim();
  }
  // Parallel-branch agents write their OWN signal file so two branches
  // completing at the same moment can never clobber each other. The primary
  // agent keeps the legacy shared filename.
  if (myParallelStep(state)) {
    const safeAgent = String(AGENT_ID).replace(/[^a-zA-Z0-9_-]/g, '_');
    atomicWriteJson(path.join(RUN_DIR, `step-complete-${safeAgent}.json`), signal);
  } else {
    atomicWriteJson(STEP_COMPLETE_FILE, signal);
  }
  return {
    ok: true,
    text:
      'Step marked complete. The team runner will pick up the handoff momentarily. Stay in this session — if the team loops back to your node a new message will appear here.',
  };
}

// ── 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 } });
}

function toolResponse(outcome) {
  if (Array.isArray(outcome)) {
    return { content: outcome, isError: false };
  }
  if (outcome && outcome.error) {
    return { content: [{ type: 'text', text: String(outcome.error) }], isError: true };
  }
  if (outcome && outcome.ok) {
    return { content: [{ type: 'text', text: outcome.text || 'ok' }], isError: false };
  }
  return { content: [{ type: 'text', text: 'ok' }], isError: false };
}

function handleToolCall(name, args) {
  const a = args || {};
  switch (name) {
    case 'team_get_context': return toolGetContext();
    case 'team_read_notes': return toolReadNotes(a);
    case 'team_post_note': return toolPostNote(a);
    case 'team_read_timeline': return toolReadTimeline(a);
    case 'team_read_inbox': return toolReadInbox(a);
    case 'team_ask': return toolAsk(a);
    case 'team_read_diff': return toolReadDiff(a);
    case 'team_complete_step': return toolCompleteStep(a);
    default: throw new Error('Unknown tool: ' + name);
  }
}

const rl = readline.createInterface({ input: process.stdin });
rl.on('line', (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-team', version: SERVER_VERSION },
      });
      return;
    }
    if (msg.method === 'tools/list') {
      sendResult(msg.id, { tools: TEAM_ENV_OK ? TOOLS : [] });
      return;
    }
    if (msg.method === 'tools/call') {
      if (!TEAM_ENV_OK) {
        sendResult(msg.id, {
          content: [{
            type: 'text',
            text: 'Team tools are unavailable: this session is not part of an AgentsRoom team run.',
          }],
          isError: true,
        });
        return;
      }
      const params = msg.params || {};
      try {
        const outcome = handleToolCall(params.name, params.arguments);
        sendResult(msg.id, toolResponse(outcome));
      } 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); });
