# Phase 7: Multi-Agent Team System — Implementation Plan

**Priority:** P6 · **Effort:** 1–2 weeks · **MVP:** In-process teammates only

---

## Phase Structure

This is the largest feature. Break into sub-phases:

| Sub-phase | What | Effort |
|---|---|---|
| 7a | Types + Team Manager + Mailbox | 3 days |
| 7b | Agent Tool: team spawning | 2 days |
| 7c | SendMessage tool + inbox polling | 2 days |
| 7d | In-process teammate isolation | 2 days |
| 7e | Testing + polish | 2 days |

---

## Step 1: Types — `src/types.ts`

```typescript
// ---- Team System Types ----

/** A member of a team. */
export interface TeamMember {
  agentId: string;
  name: string;
  agentType?: string;
  model?: string;
  color?: string;
  prompt: string;
  joinedAt: number;
  subscriptions: string[];
  backendType: "in-process" | "tmux" | "iterm2";
  cwd?: string;
}

/** A team definition persisted to disk. */
export interface TeamDefinition {
  version: 1;
  name: string;
  createdAt: string;
  leadAgentId: string;
  members: TeamMember[];
}

/** A message in the inter-agent mailbox. */
export interface MailboxMessage {
  id: string;
  from: string;
  to: string;
  text: string;
  timestamp: string;
  delivered: boolean;
}

/** State for the agent's team context (runtime, not persisted). */
export interface TeamContext {
  teamName: string;
  isLead: boolean;
  agentName?: string;          // set for teammates
  memberNames: string[];       // all members including self
}
```

---

## Step 2: Team Manager — `src/team-manager.ts`

```typescript
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { nanoid } from "nanoid";
import type { TeamDefinition, TeamMember } from "./types.js";

const TEAMS_DIR = ".pi/teams";

export class TeamManager {
  private teams = new Map<string, TeamDefinition>();

  constructor(private cwd: string) {
    this.loadAll();
  }

  private teamsDir(): string {
    return join(this.cwd, TEAMS_DIR);
  }

  private teamPath(name: string): string {
    return join(this.teamsDir(), `${sanitizeFileName(name)}.json`);
  }

  /** Load all existing team files from disk. */
  private loadAll(): void {
    const dir = this.teamsDir();
    if (!existsSync(dir)) return;
    const files = readdirSync(dir).filter(f => f.endsWith(".json"));
    for (const file of files) {
      try {
        const data = JSON.parse(readFileSync(join(dir, file), "utf-8"));
        this.teams.set(data.name, data);
      } catch { /* skip corrupt files */ }
    }
  }

  /** Get or create a team. */
  getOrCreate(name: string, leadAgentId: string): TeamDefinition {
    let team = this.teams.get(name);
    if (!team) {
      team = {
        version: 1,
        name,
        createdAt: new Date().toISOString(),
        leadAgentId,
        members: [],
      };
      this.teams.set(name, team);
    }
    this.persist(name);
    return team;
  }

  /** Get a team by name. */
  get(name: string): TeamDefinition | undefined {
    return this.teams.get(name);
  }

  /** Add a member to a team. */
  addMember(teamName: string, member: TeamMember): void {
    const team = this.teams.get(teamName);
    if (!team) throw new Error(`Team "${teamName}" does not exist`);
    team.members.push(member);
    this.persist(teamName);
  }

  /** Remove a member from a team. */
  removeMember(teamName: string, agentId: string): void {
    const team = this.teams.get(teamName);
    if (!team) return;
    team.members = team.members.filter(m => m.agentId !== agentId);
    if (team.members.length === 0) {
      this.teams.delete(teamName);
    }
    this.persist(teamName);
  }

  /** Get member names for a team. */
  getMemberNames(teamName: string): string[] {
    return this.teams.get(teamName)?.members.map(m => m.name) ?? [];
  }

  /** Generate a unique name within a team. */
  generateUniqueName(teamName: string, baseName: string): string {
    const existing = new Set(this.getMemberNames(teamName).map(n => n.toLowerCase()));
    if (!existing.has(baseName.toLowerCase())) return baseName;
    let suffix = 2;
    while (existing.has(`${baseName}-${suffix}`.toLowerCase())) suffix++;
    return `${baseName}-${suffix}`;
  }

  /** Format an agent ID from name and team. */
  formatAgentId(name: string, teamName: string): string {
    return `${sanitizeAgentName(name)}@${sanitizeAgentName(teamName)}`;
  }

  private persist(name: string): void {
    const team = this.teams.get(name);
    if (!team) return;
    const dir = this.teamsDir();
    if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
    writeFileSync(this.teamPath(name), JSON.stringify(team, null, 2));
  }

  /** Get all team names. */
  listTeams(): string[] {
    return [...this.teams.keys()];
  }

  /** Remove a team entirely. */
  disband(name: string): void {
    this.teams.delete(name);
    const path = this.teamPath(name);
    if (existsSync(path)) unlinkSync(path);
  }
}

function sanitizeFileName(name: string): string {
  return name.replace(/[^a-zA-Z0-9_-]/g, "_");
}

function sanitizeAgentName(name: string): string {
  return name.replace(/[^a-zA-Z0-9._-]/g, "");
}
```

---

## Step 3: Mailbox — `src/mailbox.ts`

```typescript
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { nanoid } from "nanoid";
import type { MailboxMessage } from "./types.js";

const MAILBOXES_DIR = ".pi/teams/mailboxes";

export class Mailbox {
  constructor(private cwd: string) {}

  private inboxDir(agentName: string, teamName: string): string {
    return join(this.cwd, MAILBOXES_DIR, teamName, agentName, "inbox");
  }

  private outboxDir(agentName: string, teamName: string): string {
    return join(this.cwd, MAILBOXES_DIR, teamName, agentName, "outbox");
  }

  /** Send a message to an agent's inbox. */
  send(from: string, to: string, text: string, teamName: string): void {
    const inboxDir = this.inboxDir(to, teamName);
    if (!existsSync(inboxDir)) mkdirSync(inboxDir, { recursive: true });

    const message: MailboxMessage = {
      id: nanoid(12),
      from,
      to,
      text,
      timestamp: new Date().toISOString(),
      delivered: false,
    };

    writeFileSync(join(inboxDir, `${message.id}.json`), JSON.stringify(message));
  }

  /** Read all pending messages for an agent. Clears them after reading. */
  readMessages(agentName: string, teamName: string): MailboxMessage[] {
    const inboxDir = this.inboxDir(agentName, teamName);
    if (!existsSync(inboxDir)) return [];

    const messages: MailboxMessage[] = [];
    const files = readdirSync(inboxDir).filter(f => f.endsWith(".json"));

    for (const file of files) {
      try {
        const msg = JSON.parse(readFileSync(join(inboxDir, file), "utf-8"));
        messages.push(msg);
        unlinkSync(join(inboxDir, file));  // Remove after reading
      } catch { /* skip corrupt messages */ }
    }

    return messages;
  }

  /** Write a message to the agent's outbox (for SendMessage response). */
  writeOutbox(agentName: string, teamName: string, message: MailboxMessage): void {
    const outboxDir = this.outboxDir(agentName, teamName);
    if (!existsSync(outboxDir)) mkdirSync(outboxDir, { recursive: true });
    writeFileSync(join(outboxDir, `${message.id}.json`), JSON.stringify(message));
  }

  /** Clean up a teammate's mailbox. */
  cleanAgent(agentName: string, teamName: string): void {
    const inboxDir = this.inboxDir(agentName, teamName);
    const outboxDir = this.outboxDir(agentName, teamName);
    try { rmSync(inboxDir, { recursive: true }); } catch { /* ignore */ }
    try { rmSync(outboxDir, { recursive: true }); } catch { /* ignore */ }
  }

  /** Clean up all mailboxes for a team. */
  cleanTeam(teamName: string): void {
    const dir = join(this.cwd, MAILBOXES_DIR, teamName);
    try { rmSync(dir, { recursive: true }); } catch { /* ignore */ }
  }
}
```

---

## Step 4: Modify `src/index.ts` — Team-Aware Agent Tool

### 4.1. Add New Parameters to Agent Tool

```typescript
name: Type.Optional(Type.String({
  description: "Name for this agent. Makes it addressable via SendMessage({to: name}). Required for team participation.",
})),
team_name: Type.Optional(Type.String({
  description: "Team name. Creates or joins a team. Required when name is set.",
})),
```

### 4.2. Team Spawn Logic

In the Agent tool `execute` handler, when `name` and `team_name` are provided:

```typescript
// ---- Team spawning path ----
if (params.name && params.team_name) {
  const teamName = params.team_name;
  const agentName = params.name;

  // Only the lead agent can spawn teammates
  if (!ctx.teamContext?.isLead && ctx.teamContext) {
    return textResult("Only the lead agent can spawn teammates.");
  }

  // Generate unique name
  const uniqueName = teamManager.generateUniqueName(teamName, agentName);
  const agentId = teamManager.formatAgentId(uniqueName, teamName);

  // Create team if needed
  const team = teamManager.getOrCreate(teamName, ctx.agentId ?? agentId);

  // Add member
  teamManager.addMember(teamName, {
    agentId,
    name: uniqueName,
    agentType: subagentType,
    color,
    prompt: params.prompt,
    joinedAt: Date.now(),
    subscriptions: [],
    backendType: "in-process",
  });

  // Create mailbox
  const mailbox = new Mailbox(ctx.cwd);

  // Spawn in-process teammate (fire and forget)
  spawnInProcessTeammate({
    pi,
    ctx,
    agentId,
    name: uniqueName,
    teamName,
    type: subagentType,
    prompt: params.prompt,
    model,
    maxTurns: effectiveMaxTurns,
    thinkingLevel: thinking,
  });

  return textResult(
    `Teammate "${uniqueName}" spawned in team "${teamName}".\n` +
    `Agent ID: ${agentId}\n` +
    `Use SendMessage({to: "${uniqueName}", message: "..."}) to communicate.\n` +
    `Use get_subagent_result({agent_id: "${agentId}"}) to check results.`,
    { ...detailBase, status: "background", agentId },
  );
}
```

---

## Step 5: Register `SendMessage` Tool

In `src/index.ts`:

```typescript
pi.registerTool(defineTool({
  name: "SendMessage",
  label: "Send Message",
  description: "Send a message to a named agent in the current team. The message will appear in the recipient's conversation on their next turn.",
  parameters: Type.Object({
    to: Type.String({ description: "Name of the recipient agent (e.g., 'researcher')." }),
    message: Type.String({ description: "The message to send." }),
  }),
  execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
    const teamContext = (ctx as any).teamContext;
    if (!teamContext?.teamName) {
      return textResult("No active team. Spawn a teammate with `name` and `team_name` first.");
    }

    const memberNames = teamManager.getMemberNames(teamContext.teamName);
    if (!memberNames.includes(params.to)) {
      return textResult(`Agent "${params.to}" not found in team "${teamContext.teamName}". Active members: ${memberNames.join(", ")}`);
    }

    const mailbox = new Mailbox(ctx.cwd);
    mailbox.send(ctx.agentId ?? "lead", params.to, params.message, teamContext.teamName);

    return textResult(`Message sent to "${params.to}". It will be delivered on their next turn.`);
  },
}));
```

---

## Step 6: In-Process Teammate — `src/in-process-teammate.ts`

```typescript
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { runAgent } from "./agent-runner.js";
import { AgentManager } from "./agent-manager.js";
import { Mailbox } from "./mailbox.js";
import type { Model } from "@mariozechner/pi-ai";
import { AsyncLocalStorage } from "node:async_hooks";

/**
 * In-process teammate execution.
 * Runs the agent in the same Node.js process with AsyncLocalStorage isolation.
 */

interface TeammateConfig {
  pi: ExtensionAPI;
  ctx: ExtensionContext;
  agentId: string;
  name: string;
  teamName: string;
  type: string;
  prompt: string;
  model?: Model<any>;
  maxTurns?: number;
  thinkingLevel?: string;
}

const teammateStorage = new AsyncLocalStorage<{ agentId: string; name: string; teamName: string; mailbox: Mailbox }>();

/** Get the current teammate's context (for inbox polling). */
export function getTeammateContext() {
  return teammateStorage.getStore();
}

export async function spawnInProcessTeammate(config: TeammateConfig): Promise<void> {
  const mailbox = new Mailbox(config.ctx.cwd);

  // Set up the teammate context for inbox polling
  const store = {
    agentId: config.agentId,
    name: config.name,
    teamName: config.teamName,
    mailbox,
  };

  // Start the agent in its own AsyncLocalStorage context
  await teammateStorage.run(store, async () => {
    const { responseText } = await runAgent(
      config.ctx,
      config.type,
      config.prompt,
      {
        pi: config.pi,
        model: config.model,
        maxTurns: config.maxTurns,
        thinkingLevel: config.thinkingLevel,
        // Wire inbox polling: between turns, check for new messages
        onTurnEnd: (turnCount) => {
          // Read pending messages from inbox
          const messages = mailbox.readMessages(config.name, config.teamName);
          if (messages.length > 0) {
            // The messages will be injected as user messages by the run loop
            // This requires the agent-runner to support mid-run message injection
          }
        },
      },
    );
  });
}
```

**Inbox polling integration**: The agent-runner needs to support checking the inbox between turns. Add a callback `onBetweenTurns` that returns pending messages. These are injected as user messages in the conversation before the next LLM query.

---

## Step 7: Wire Team Context Through Agent Sessions

The `AgentRecord` and `AgentSession` need team context for:
- `SendMessage` to know the current team
- The lead to know who's on the team
- Teammates to know they're on a team (for blocking sub-spawning)

Add to `types.ts`:

```typescript
export interface AgentRecord {
  // ...existing...
  teamContext?: {
    teamName: string;
    agentName?: string;  // set for teammates, undefined for lead
    isLead: boolean;
  };
}
```

---

## Step 8: Tests

### 8.1. `test/team-manager.test.ts`

| # | Test | Expected |
|---|---|---|
| 1 | Create team | Team file created at `.pi/teams/<name>.json` |
| 2 | Add member | Member reflected in `get()` and `getMemberNames()` |
| 3 | Remove member | Member removed; team deleted if last member |
| 4 | Unique names | `generateUniqueName("team", "bot")` → "bot", then "bot-2" |
| 5 | Agent ID format | `formatAgentId("alice", "team-x")` → "alice@team-x" |
| 6 | Load existing teams | `loadAll()` reads persisted team files |

### 8.2. `test/mailbox.test.ts`

| # | Test | Expected |
|---|---|---|
| 1 | Send message | File created in inbox directory |
| 2 | Read messages | Returns message, file removed |
| 3 | Empty inbox | Returns `[]` |
| 4 | Clean agent | Mailbox files removed |
| 5 | Multiple messages | All messages read and cleared |

---

## File Change Summary

| Action | File |
|---|---|
| **MODIFY** | `src/types.ts` — team types, mailbox types, teamContext on AgentRecord |
| **CREATE** | `src/team-manager.ts` (~200 lines) |
| **CREATE** | `src/mailbox.ts` (~130 lines) |
| **CREATE** | `src/in-process-teammate.ts` (~100 lines) |
| **MODIFY** | `src/index.ts` — team-aware Agent tool, SendMessage tool |
| **MODIFY** | `src/agent-runner.ts` — inbox polling callback, message injection |
| **CREATE** | `test/team-manager.test.ts` (~120 lines) |
| **CREATE** | `test/mailbox.test.ts` (~100 lines) |
