agenthub/docs/plans/2026-06-25-agenthub-network-mode.md
2026-06-25 12:46:16 +02:00

48 KiB

AgentHub Network Mode Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a hybrid remote mode to AgentHub so that one machine can host the .agenthub/ state and expose it via LAN, while other machines use the CLI over HTTP.

Architecture: Extract service modules from the CLI commands so the same business logic can be reused by both the CLI and the Fastify server. Add a --server / AGENTHUB_SERVER client mode that delegates CLI calls to HTTP endpoints. Extend the server with full CRUD endpoints and a --host option for LAN binding.

Tech Stack: TypeScript 5.x, Node.js ≥20, pnpm, Fastify, Vitest, better-sqlite3.


File Structure

File Responsibility
src/core/services/taskService.ts Task CRUD + indexing
src/core/services/handoffService.ts Handoff CRUD + indexing
src/core/services/decisionService.ts Decision CRUD + indexing
src/core/services/memoryService.ts Memory CRUD + indexing
src/core/services/statusService.ts Status read/update
src/core/services/delegateService.ts Delegation suggestion + auto handoff
src/cli/remoteClient.ts HTTP client for remote CLI mode
src/cli/commands/*.ts Refactored to call services or RemoteClient
src/cli/index.ts Global --server option and command wiring
src/server/index.ts --host support and reusable buildApp
src/server/routes.ts Full CRUD endpoints using services
tests/server.test.ts Route tests via app.inject
tests/remoteClient.test.ts RemoteClient tests with mocked fetch
tests/e2e-network.test.ts CLI --server end-to-end test

Task 1: Create task service module

Files:

  • Create: src/core/services/taskService.ts

  • Test: tests/taskService.test.ts

  • Step 1: Write the failing test

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { createTask, listTasks, getTask, claimTask, doneTask } from '../../src/core/services/taskService.js';

describe('taskService', () => {
  let cwd: string;

  beforeEach(() => {
    cwd = mkdtempSync(join(tmpdir(), 'ah-task-'));
  });

  afterEach(() => {
    rmSync(cwd, { recursive: true, force: true });
  });

  it('creates a task', () => {
    const task = createTask(cwd, { title: 'Test', role: 'implementer' });
    expect(task.id).toBe('TSK-0001');
    expect(task.title).toBe('Test');
    expect(task.status).toBe('open');
  });

  it('lists tasks', () => {
    createTask(cwd, { title: 'A', role: 'implementer' });
    expect(listTasks(cwd)).toHaveLength(1);
  });

  it('claims and completes a task', () => {
    const task = createTask(cwd, { title: 'B', role: 'implementer' });
    const claimed = claimTask(cwd, task.id, 'codex');
    expect(claimed.status).toBe('in_progress');
    expect(claimed.assignedTo).toBe('codex');
    const done = doneTask(cwd, task.id);
    expect(done.status).toBe('done');
  });
});
  • Step 2: Run test to verify it fails
cd /Users/chahinebrini/mono/agenthub
pnpm test tests/taskService.test.ts

Expected: FAIL (module not found).

  • Step 3: Implement taskService.ts
import { join } from 'path';
import { getEntityDir } from '../paths.js';
import { getNextId } from '../counter.js';
import { readEntity, writeEntity } from '../files.js';
import { TaskSchema, type Task } from '../schema.js';
import { Index } from '../index.js';

export function createTask(cwd: string, options: Partial<Task> = {}): Task {
  const now = new Date().toISOString();
  const task: Task = TaskSchema.parse({
    id: getNextId(cwd, 'task'),
    title: options.title ?? 'Untitled',
    description: options.description ?? '',
    status: 'open',
    priority: options.priority ?? 'medium',
    role: options.role,
    assignedTo: options.assignedTo,
    createdAt: now,
    updatedAt: now,
  });

  const filePath = join(getEntityDir(cwd, 'tasks'), `${task.id}.md`);
  writeEntity(filePath, task, `# ${task.title}\n\n${task.description}`);

  const index = new Index(cwd);
  index.upsert(toIndexEntry(task, filePath));
  index.close();

  return task;
}

export function listTasks(cwd: string, filters?: { status?: string; role?: string }): ReturnType<Index['list']> {
  const index = new Index(cwd);
  const tasks = index.list('task', filters);
  index.close();
  return tasks;
}

export function getTask(cwd: string, id: string): { task: Task; body: string; filePath: string } {
  const filePath = join(getEntityDir(cwd, 'tasks'), `${id}.md`);
  const { frontmatter, body } = readEntity(filePath);
  return { task: TaskSchema.parse(frontmatter), body, filePath };
}

export function updateTask(cwd: string, id: string, patch: Partial<Task>): Task {
  const { task, body, filePath } = getTask(cwd, id);
  const updated: Task = TaskSchema.parse({ ...task, ...patch, updatedAt: new Date().toISOString() });
  writeEntity(filePath, updated, body);

  const index = new Index(cwd);
  index.upsert(toIndexEntry(updated, filePath));
  index.close();

  return updated;
}

export function claimTask(cwd: string, id: string, agentName: string): Task {
  return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName });
}

export function doneTask(cwd: string, id: string): Task {
  return updateTask(cwd, id, { status: 'done' });
}

function toIndexEntry(task: Task, filePath: string) {
  return {
    id: task.id,
    type: 'task',
    title: task.title,
    content: `${task.description} ${task.tags.join(' ')}`,
    filePath,
    createdAt: task.createdAt,
    updatedAt: task.updatedAt,
    status: task.status,
    role: task.role,
    assignedTo: task.assignedTo,
    tags: JSON.stringify(task.tags),
  };
}
  • Step 4: Run tests
pnpm test tests/taskService.test.ts

Expected: PASS.

  • Step 5: Commit
git add src/core/services/taskService.ts tests/taskService.test.ts
git commit -m "feat(services): add task service module"

Task 2: Refactor task CLI command to use taskService

Files:

  • Modify: src/cli/commands/task.ts

  • Step 1: Replace task.ts content

import { input, select } from '@inquirer/prompts';
import { createTask, listTasks, getTask, claimTask, doneTask } from '../../core/services/taskService.js';
import type { Task } from '../../core/schema.js';

export async function taskCreate(cwd: string, options: Partial<Task> = {}): Promise<void> {
  const title = options.title ?? await input({ message: 'Task title:' });
  const role = options.role ?? await select({
    message: 'Role:',
    choices: [
      { name: 'architect', value: 'architect' },
      { name: 'implementer', value: 'implementer' },
      { name: 'reviewer', value: 'reviewer' },
      { name: 'tester', value: 'tester' },
    ],
  });
  const priority = options.priority ?? 'medium';

  const task = createTask(cwd, { title, role, priority });
  console.log(`Created ${task.id}: ${task.title}`);
}

export function taskList(cwd: string, filters?: { status?: string; role?: string }): void {
  const tasks = listTasks(cwd, filters);
  if (tasks.length === 0) {
    console.log('No tasks found.');
    return;
  }
  for (const t of tasks) {
    console.log(`${t.id} [${t.status}] (${t.role ?? 'unassigned'}) ${t.title}`);
  }
}

export function taskShow(cwd: string, id: string): void {
  const { task, body } = getTask(cwd, id);
  console.log(`# ${task.title}`);
  console.log(`Status: ${task.status} | Role: ${task.role ?? '-'} | Priority: ${task.priority}`);
  console.log('\n' + body);
}

export function taskClaim(cwd: string, id: string, agentName: string): void {
  claimTask(cwd, id, agentName);
  console.log(`${id} claimed by ${agentName}.`);
}

export function taskDone(cwd: string, id: string): void {
  doneTask(cwd, id);
  console.log(`${id} marked as done.`);
}
  • Step 2: Run task command tests
pnpm test tests/task-cmd.test.ts

Expected: PASS.

  • Step 3: Commit
git add src/cli/commands/task.ts
git commit -m "refactor(cli): use taskService in task command"

Task 3: Create handoff service module

Files:

  • Create: src/core/services/handoffService.ts

  • Test: tests/handoffService.test.ts

  • Step 1: Write the failing test

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { createHandoff, listHandoffs, getHandoff } from '../../src/core/services/handoffService.js';

describe('handoffService', () => {
  let cwd: string;

  beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), 'ah-hof-')); });
  afterEach(() => { rmSync(cwd, { recursive: true, force: true }); });

  it('creates and reads a handoff', () => {
    const h = createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', summary: 's', context: 'c' });
    expect(h.id).toBe('HOF-0001');
    expect(getHandoff(cwd, h.id).handoff.summary).toBe('s');
    expect(listHandoffs(cwd)).toHaveLength(1);
  });
});
  • Step 2: Implement handoffService.ts
import { join } from 'path';
import { getEntityDir } from '../paths.js';
import { getNextId } from '../counter.js';
import { readEntity, writeEntity } from '../files.js';
import { HandoffSchema, type Handoff } from '../schema.js';
import { Index } from '../index.js';

export function createHandoff(cwd: string, options: Partial<Handoff> = {}): Handoff {
  const now = new Date().toISOString();
  const handoff: Handoff = HandoffSchema.parse({
    id: getNextId(cwd, 'handoff'),
    fromRole: options.fromRole ?? 'user',
    toRole: options.toRole ?? 'user',
    fromAgent: options.fromAgent,
    toAgent: options.toAgent,
    taskId: options.taskId,
    summary: options.summary ?? 'Handoff',
    context: options.context ?? '',
    decisions: options.decisions ?? [],
    openQuestions: options.openQuestions ?? [],
    nextSteps: options.nextSteps ?? [],
    createdAt: now,
  });

  const filePath = join(getEntityDir(cwd, 'handoffs'), `${handoff.id}.md`);
  writeEntity(filePath, handoff, `# ${handoff.summary}\n\n${handoff.context}`);

  const index = new Index(cwd);
  index.upsert({
    id: handoff.id,
    type: 'handoff',
    title: handoff.summary,
    content: handoff.context,
    filePath,
    createdAt: handoff.createdAt,
    updatedAt: handoff.createdAt,
  });
  index.close();

  return handoff;
}

export function listHandoffs(cwd: string): ReturnType<Index['list']> {
  const index = new Index(cwd);
  const handoffs = index.list('handoff');
  index.close();
  return handoffs;
}

export function getHandoff(cwd: string, id: string): { handoff: Handoff; body: string; filePath: string } {
  const filePath = join(getEntityDir(cwd, 'handoffs'), `${id}.md`);
  const { frontmatter, body } = readEntity(filePath);
  return { handoff: HandoffSchema.parse(frontmatter), body, filePath };
}
  • Step 3: Run tests and commit
pnpm test tests/handoffService.test.ts
git add src/core/services/handoffService.ts tests/handoffService.test.ts
git commit -m "feat(services): add handoff service module"

Task 4: Refactor handoff CLI command

Files:

  • Modify: src/cli/commands/handoff.ts

  • Step 1: Replace handoff.ts content

import { input, select } from '@inquirer/prompts';
import { createHandoff, listHandoffs, getHandoff } from '../../core/services/handoffService.js';
import type { Handoff } from '../../core/schema.js';

const roles = ['architect', 'implementer', 'reviewer', 'tester'];

export async function handoffCreate(cwd: string, options: Partial<Handoff> = {}): Promise<void> {
  const fromRole = options.fromRole ?? await select({ message: 'From role:', choices: roles.map((r) => ({ name: r, value: r })) });
  const toRole = options.toRole ?? await select({ message: 'To role:', choices: roles.map((r) => ({ name: r, value: r })) });
  const taskId = options.taskId ?? await input({ message: 'Related task id (optional):' });
  const summary = options.summary ?? await input({ message: 'Summary:' });
  const context = options.context ?? await input({ message: 'Context:' });

  const handoff = createHandoff(cwd, {
    fromRole,
    toRole,
    fromAgent: options.fromAgent,
    toAgent: options.toAgent,
    taskId: taskId || undefined,
    summary,
    context,
  });

  console.log(`Handoff created: ${handoff.id}`);
}

export function handoffRead(cwd: string, id: string): void {
  const { handoff, body } = getHandoff(cwd, id);
  console.log(`# ${handoff.summary}`);
  console.log(`From: ${handoff.fromRole}${handoff.toRole}`);
  if (handoff.taskId) console.log(`Task: ${handoff.taskId}`);
  console.log('\n' + body);
}

export function handoffList(cwd: string): void {
  const handoffs = listHandoffs(cwd);
  if (handoffs.length === 0) {
    console.log('No handoffs found.');
    return;
  }
  for (const h of handoffs) {
    console.log(`${h.id}: ${h.title}`);
  }
}
  • Step 2: Run existing tests and commit
pnpm test
git add src/cli/commands/handoff.ts
git commit -m "refactor(cli): use handoffService in handoff command"

Task 5: Create decision service module

Files:

  • Create: src/core/services/decisionService.ts

  • Test: tests/decisionService.test.ts

  • Step 1: Implement decisionService.ts

import { join } from 'path';
import { getEntityDir } from '../paths.js';
import { getNextId } from '../counter.js';
import { readEntity, writeEntity } from '../files.js';
import { DecisionSchema, type Decision } from '../schema.js';
import { Index } from '../index.js';

export function createDecision(cwd: string, options: Partial<Decision> = {}): Decision {
  const now = new Date().toISOString();
  const record: Decision = DecisionSchema.parse({
    id: getNextId(cwd, 'decision'),
    title: options.title ?? 'Decision',
    context: options.context ?? '',
    decision: options.decision ?? '',
    status: options.status ?? 'accepted',
    consequences: options.consequences ?? [],
    alternatives: options.alternatives ?? [],
    relatedDecisions: options.relatedDecisions ?? [],
    createdAt: now,
    updatedAt: now,
  });

  const filePath = join(getEntityDir(cwd, 'decisions'), `${record.id}.md`);
  writeEntity(filePath, record, `# ${record.title}\n\n## Decision\n\n${record.decision}\n\n## Context\n\n${record.context}`);

  const index = new Index(cwd);
  index.upsert({
    id: record.id,
    type: 'decision',
    title: record.title,
    content: `${record.context} ${record.decision}`,
    filePath,
    createdAt: record.createdAt,
    updatedAt: record.updatedAt,
  });
  index.close();

  return record;
}

export function listDecisions(cwd: string): ReturnType<Index['list']> {
  const index = new Index(cwd);
  const decisions = index.list('decision');
  index.close();
  return decisions;
}

export function getDecision(cwd: string, id: string): { decision: Decision; body: string; filePath: string } {
  const filePath = join(getEntityDir(cwd, 'decisions'), `${id}.md`);
  const { frontmatter, body } = readEntity(filePath);
  return { decision: DecisionSchema.parse(frontmatter), body, filePath };
}
  • Step 2: Refactor decision.ts CLI command
import { input } from '@inquirer/prompts';
import { createDecision, listDecisions } from '../../core/services/decisionService.js';
import type { Decision } from '../../core/schema.js';

export async function decisionCreate(cwd: string, options: Partial<Decision> = {}): Promise<void> {
  const title = options.title ?? await input({ message: 'Decision title:' });
  const context = options.context ?? await input({ message: 'Context:' });
  const decision = options.decision ?? await input({ message: 'Decision:' });

  const record = createDecision(cwd, { title, context, decision });
  console.log(`Decision recorded: ${record.id}`);
}

export function decisionList(cwd: string): void {
  const decisions = listDecisions(cwd);
  if (decisions.length === 0) {
    console.log('No decisions found.');
    return;
  }
  for (const d of decisions) {
    console.log(`${d.id}: ${d.title}`);
  }
}
  • Step 3: Run tests and commit
pnpm test tests/decisionService.test.ts
git add src/core/services/decisionService.ts tests/decisionService.test.ts src/cli/commands/decision.ts
git commit -m "feat(services): add decision service and refactor CLI"

Task 6: Create memory service module

Files:

  • Create: src/core/services/memoryService.ts

  • Test: tests/memoryService.test.ts

  • Step 1: Implement memoryService.ts

import { join } from 'path';
import { getEntityDir } from '../paths.js';
import { writeEntity } from '../files.js';
import { MemorySchema, type Memory } from '../schema.js';
import { Index } from '../index.js';

export function addMemory(cwd: string, options: Partial<Memory> = {}): Memory {
  const now = new Date().toISOString();
  const id = `MEM-${Date.now()}`;
  const memory: Memory = MemorySchema.parse({
    id,
    title: options.title ?? 'Memory',
    category: options.category ?? 'technical',
    content: options.content ?? '',
    tags: options.tags ?? [],
    relatedTasks: options.relatedTasks ?? [],
    relatedDecisions: options.relatedDecisions ?? [],
    createdAt: now,
    updatedAt: now,
  });

  const filePath = join(getEntityDir(cwd, 'memory'), `${memory.id}.md`);
  writeEntity(filePath, memory, `# ${memory.title}\n\n${memory.content}`);

  const index = new Index(cwd);
  index.upsert({
    id: memory.id,
    type: 'memory',
    title: memory.title,
    content: memory.content,
    filePath,
    createdAt: memory.createdAt,
    updatedAt: memory.updatedAt,
    tags: JSON.stringify(memory.tags),
  });
  index.close();

  return memory;
}

export function searchMemory(cwd: string, query: string): ReturnType<Index['search']> {
  const index = new Index(cwd);
  const results = index.search(query);
  index.close();
  return results;
}

export function listMemory(cwd: string): ReturnType<Index['list']> {
  const index = new Index(cwd);
  const memories = index.list('memory');
  index.close();
  return memories;
}
  • Step 2: Refactor memory.ts CLI command
import { input, select } from '@inquirer/prompts';
import { addMemory, searchMemory, listMemory } from '../../core/services/memoryService.js';
import type { Memory } from '../../core/schema.js';

export async function memoryAdd(cwd: string, options: Partial<Memory> = {}): Promise<void> {
  const title = options.title ?? await input({ message: 'Memory title:' });
  const category = options.category ?? await select({
    message: 'Category:',
    choices: [
      { name: 'architecture', value: 'architecture' },
      { name: 'product', value: 'product' },
      { name: 'technical', value: 'technical' },
      { name: 'implementation', value: 'implementation' },
      { name: 'lesson', value: 'lesson' },
    ],
  });
  const content = options.content ?? await input({ message: 'Content:' });

  const memory = addMemory(cwd, { title, category, content });
  console.log(`Memory saved as ${memory.id}.`);
}

export function memorySearch(cwd: string, query: string): void {
  const results = searchMemory(cwd, query);
  if (results.length === 0) {
    console.log('No results found.');
    return;
  }
  for (const r of results) {
    console.log(`[${r.type}] ${r.id}: ${r.title}`);
  }
}

export function memoryList(cwd: string): void {
  const memories = listMemory(cwd);
  if (memories.length === 0) {
    console.log('No memory entries found.');
    return;
  }
  for (const m of memories) {
    console.log(`${m.id}: ${m.title}`);
  }
}
  • Step 3: Run tests and commit
pnpm test tests/memoryService.test.ts
git add src/core/services/memoryService.ts tests/memoryService.test.ts src/cli/commands/memory.ts
git commit -m "feat(services): add memory service and refactor CLI"

Task 7: Create status service module

Files:

  • Create: src/core/services/statusService.ts

  • Modify: src/cli/commands/status.ts

  • Step 1: Implement statusService.ts

import { existsSync } from 'fs';
import { generateStatus } from '../status.js';
import { readEntity } from '../files.js';
import { getStatusPath } from '../paths.js';

export function getStatus(cwd: string): string {
  const statusPath = getStatusPath(cwd);
  if (!existsSync(statusPath)) {
    generateStatus(cwd);
  }
  const { body } = readEntity(statusPath);
  return body;
}

export function updateStatus(cwd: string): string {
  generateStatus(cwd);
  return getStatus(cwd);
}
  • Step 2: Refactor status.ts CLI command
import { getStatus, updateStatus } from '../../core/services/statusService.js';

export function status(cwd: string, options: { update?: boolean } = {}): void {
  const body = options.update ? updateStatus(cwd) : getStatus(cwd);
  console.log(body);
}
  • Step 3: Run tests and commit
pnpm test tests/status.test.ts
git add src/core/services/statusService.ts src/cli/commands/status.ts
git commit -m "feat(services): add status service and refactor CLI"

Task 8: Create delegate service module

Files:

  • Create: src/core/services/delegateService.ts

  • Modify: src/cli/commands/delegate.ts

  • Step 1: Implement delegateService.ts

import { loadConfig } from '../config.js';
import { Index } from '../index.js';
import { createHandoff } from './handoffService.js';
import type { Handoff, IndexEntry } from '../schema.js';

export interface DelegationSuggestion {
  task: IndexEntry;
  role: string;
  preferredAgent: string;
}

export function suggestDelegation(cwd: string): DelegationSuggestion | null {
  const config = loadConfig(cwd);
  const index = new Index(cwd);
  const openTasks = index.list('task', { status: 'open' });
  index.close();

  if (openTasks.length === 0) return null;

  const task = openTasks[0];
  const role = task.role ?? 'implementer';
  const preferredAgent = config.roles[role]?.preferredAgent ?? 'codex';
  return { task, role, preferredAgent };
}

export function autoDelegate(cwd: string): Handoff | null {
  const suggestion = suggestDelegation(cwd);
  if (!suggestion) return null;
  return createHandoff(cwd, {
    fromRole: 'user',
    toRole: suggestion.role,
    toAgent: suggestion.preferredAgent,
    taskId: suggestion.task.id,
    summary: `Delegate ${suggestion.task.id} to ${suggestion.role}`,
    context: `Task "${suggestion.task.title}" should be handled by ${suggestion.preferredAgent} in ${suggestion.role} role.`,
  });
}
  • Step 2: Refactor delegate.ts CLI command
import { suggestDelegation, autoDelegate } from '../../core/services/delegateService.js';

export async function delegate(cwd: string, options: { auto?: boolean } = {}): Promise<void> {
  const suggestion = suggestDelegation(cwd);
  if (!suggestion) {
    console.log('No open tasks to delegate.');
    return;
  }

  console.log('Suggested delegation:');
  console.log(`  Task: ${suggestion.task.id}${suggestion.task.title}`);
  console.log(`  Role: ${suggestion.role}`);
  console.log(`  Preferred agent: ${suggestion.preferredAgent}`);

  if (options.auto) {
    autoDelegate(cwd);
    console.log('Handoff created automatically.');
  } else {
    console.log('Run with --auto to create the handoff, or run:');
    console.log(`  agenthub handoff create --taskId ${suggestion.task.id}`);
  }
}
  • Step 3: Run tests and commit
pnpm test
git add src/core/services/delegateService.ts src/cli/commands/delegate.ts
git commit -m "feat(services): add delegate service and refactor CLI"

Task 9: Create RemoteClient

Files:

  • Create: src/cli/remoteClient.ts

  • Test: tests/remoteClient.test.ts

  • Step 1: Implement remoteClient.ts

import type { Task, Handoff, Decision, Memory, IndexEntry } from '../core/schema.js';

export class RemoteError extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}

async function request<T>(baseUrl: string, method: string, path: string, body?: unknown): Promise<T> {
  const url = `${baseUrl}${path}`;
  const res = await fetch(url, {
    method,
    headers: { 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });

  const text = await res.text();
  if (!res.ok) {
    throw new RemoteError(res.status, text || `HTTP ${res.status}`);
  }
  return text ? (JSON.parse(text) as T) : (undefined as T);
}

export const remoteClient = {
  async getStatus(baseUrl: string): Promise<string> {
    return request<{ body: string }>(baseUrl, 'GET', '/status').then((r) => r.body);
  },

  async updateStatus(baseUrl: string): Promise<string> {
    return request<{ body: string }>(baseUrl, 'POST', '/status/update').then((r) => r.body);
  },

  async createTask(baseUrl: string, options: Partial<Task>): Promise<Task> {
    return request<Task>(baseUrl, 'POST', '/tasks', options);
  },

  async listTasks(baseUrl: string, filters?: { status?: string; role?: string }): Promise<IndexEntry[]> {
    const params = new URLSearchParams(filters as Record<string, string>);
    return request<IndexEntry[]>(baseUrl, 'GET', `/tasks?${params.toString()}`);
  },

  async getTask(baseUrl: string, id: string): Promise<{ task: Task; body: string }> {
    return request<{ task: Task; body: string }>(baseUrl, 'GET', `/tasks/${id}`);
  },

  async claimTask(baseUrl: string, id: string, agentName: string): Promise<Task> {
    return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName });
  },

  async doneTask(baseUrl: string, id: string): Promise<Task> {
    return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'done' });
  },

  async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {
    return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
  },

  async listHandoffs(baseUrl: string): Promise<IndexEntry[]> {
    return request<IndexEntry[]>(baseUrl, 'GET', '/handoffs');
  },

  async getHandoff(baseUrl: string, id: string): Promise<{ handoff: Handoff; body: string }> {
    return request<{ handoff: Handoff; body: string }>(baseUrl, 'GET', `/handoffs/${id}`);
  },

  async createDecision(baseUrl: string, options: Partial<Decision>): Promise<Decision> {
    return request<Decision>(baseUrl, 'POST', '/decisions', options);
  },

  async listDecisions(baseUrl: string): Promise<IndexEntry[]> {
    return request<IndexEntry[]>(baseUrl, 'GET', '/decisions');
  },

  async addMemory(baseUrl: string, options: Partial<Memory>): Promise<Memory> {
    return request<Memory>(baseUrl, 'POST', '/memory', options);
  },

  async searchMemory(baseUrl: string, query: string): Promise<IndexEntry[]> {
    return request<IndexEntry[]>(baseUrl, 'GET', `/memory/search?q=${encodeURIComponent(query)}`);
  },

  async listMemory(baseUrl: string): Promise<IndexEntry[]> {
    return request<IndexEntry[]>(baseUrl, 'GET', '/memory');
  },

  async delegate(baseUrl: string, auto: boolean): Promise<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }> {
    return request<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }>(baseUrl, 'POST', `/delegate?auto=${auto}`);
  },
};
  • Step 2: Write remoteClient test with mocked fetch
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { remoteClient, RemoteError } from '../src/cli/remoteClient.js';

describe('remoteClient', () => {
  beforeEach(() => {
    vi.restoreAllMocks();
    globalThis.fetch = vi.fn();
  });

  it('creates a task remotely', async () => {
    const task = { id: 'TSK-0001', title: 'Remote task', status: 'open' };
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: true,
      status: 200,
      text: () => Promise.resolve(JSON.stringify(task)),
    } as Response);

    const result = await remoteClient.createTask('http://localhost:3377', { title: 'Remote task' });
    expect(result.id).toBe('TSK-0001');
  });

  it('throws RemoteError on failure', async () => {
    vi.mocked(fetch).mockResolvedValueOnce({
      ok: false,
      status: 500,
      text: () => Promise.resolve('boom'),
    } as Response);

    await expect(remoteClient.listTasks('http://localhost:3377')).rejects.toThrow(RemoteError);
  });
});
  • Step 3: Run tests and commit
pnpm test tests/remoteClient.test.ts
git add src/cli/remoteClient.ts tests/remoteClient.test.ts
git commit -m "feat(cli): add RemoteClient for network mode"

Task 10: Wire global --server option into CLI commands

Files:

  • Modify: src/cli/index.ts

  • Step 1: Update createProgram to support --server

The updated src/cli/index.ts adds a global --server option and routes every command through remoteClient when set. init remains local-only and shows an error in remote mode.

import { Command } from 'commander';
import { init } from './commands/init.js';
import { status } from './commands/status.js';
import { memoryAdd, memorySearch, memoryList } from './commands/memory.js';
import { taskCreate, taskList, taskShow, taskClaim, taskDone } from './commands/task.js';
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
import { decisionCreate, decisionList } from './commands/decision.js';
import { delegate } from './commands/delegate.js';
import { serverStart } from './commands/server.js';
import { remoteClient } from './remoteClient.js';

function getServerUrl(program: Command): string | undefined {
  return (program.opts().server as string | undefined) || process.env.AGENTHUB_SERVER;
}

function remoteOnly(): never {
  console.error('Remote mode is not supported for this command. Run it locally or omit --server.');
  process.exit(1);
}

export function createProgram(cwd: string): Command {
  const program = new Command('agenthub')
    .description('Local coordination layer for AI coding agents')
    .version('0.1.0')
    .option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');

  program
    .command('init')
    .description('Initialize AgentHub in the current directory')
    .option('-n, --project-name <name>', 'Project name')
    .option('-y, --yes', 'Use defaults without prompts')
    .action((options) => {
      if (getServerUrl(program)) remoteOnly();
      init(cwd, options);
    });

  program
    .command('status')
    .description('Show project status')
    .option('-u, --update', 'Regenerate status before showing')
    .action(async (options) => {
      const serverUrl = getServerUrl(program);
      if (serverUrl) {
        const body = options.update ? await remoteClient.updateStatus(serverUrl) : await remoteClient.getStatus(serverUrl);
        console.log(body);
      } else {
        status(cwd, options);
      }
    });

  const memoryCmd = new Command('memory').description('Manage memory entries');
  memoryCmd
    .command('add')
    .description('Add a memory entry')
    .option('--title <title>', 'Title')
    .option('--category <category>', 'Category')
    .option('--content <content>', 'Content')
    .action(async (options) => {
      const serverUrl = getServerUrl(program);
      if (serverUrl) {
        const memory = await remoteClient.addMemory(serverUrl, options);
        console.log(`Memory saved as ${memory.id}.`);
      } else {
        await memoryAdd(cwd, options);
      }
    });
  memoryCmd
    .command('search <query>')
    .description('Search memory and tasks')
    .action(async (query) => {
      const serverUrl = getServerUrl(program);
      if (serverUrl) {
        const results = await remoteClient.searchMemory(serverUrl, query);
        if (results.length === 0) { console.log('No results found.'); return; }
        for (const r of results) console.log(`[${r.type}] ${r.id}: ${r.title}`);
      } else {
        memorySearch(cwd, query);
      }
    });
  memoryCmd
    .command('list')
    .description('List memory entries')
    .action(async () => {
      const serverUrl = getServerUrl(program);
      if (serverUrl) {
        const memories = await remoteClient.listMemory(serverUrl);
        if (memories.length === 0) { console.log('No memory entries found.'); return; }
        for (const m of memories) console.log(`${m.id}: ${m.title}`);
      } else {
        memoryList(cwd);
      }
    });
  program.addCommand(memoryCmd);

  const taskCmd = new Command('task').description('Manage tasks');
  taskCmd
    .command('create')
    .description('Create a task')
    .option('--title <title>', 'Title')
    .option('--role <role>', 'Role')
    .option('--priority <priority>', 'Priority')
    .action(async (options) => {
      const serverUrl = getServerUrl(program);
      if (serverUrl) {
        const task = await remoteClient.createTask(serverUrl, options);
        console.log(`Created ${task.id}: ${task.title}`);
      } else {
        await taskCreate(cwd, options);
      }
    });
  taskCmd
    .command('list')
    .description('List tasks')
    .option('--status <status>', 'Filter by status')
    .option('--role <role>', 'Filter by role')
    .action(async (options) => {
      const serverUrl = getServerUrl(program);
      if (serverUrl) {
        const tasks = await remoteClient.listTasks(serverUrl, options);
        if (tasks.length === 0) { console.log('No tasks found.'); return; }
        for (const t of tasks) console.log(`${t.id} [${t.status}] (${t.role ?? 'unassigned'}) ${t.title}`);
      } else {
        taskList(cwd, options);
      }
    });
  taskCmd
    .command('show <id>')
    .description('Show a task')
    .action(async (id) => {
      const serverUrl = getServerUrl(program);
      if (serverUrl) {
        const { task, body } = await remoteClient.getTask(serverUrl, id);
        console.log(`# ${task.title}`);
        console.log(`Status: ${task.status} | Role: ${task.role ?? '-'} | Priority: ${task.priority}`);
        console.log('\n' + body);
      } else {
        taskShow(cwd, id);
      }
    });
  taskCmd
    .command('claim <id>')
    .description('Claim a task')
    .requiredOption('--agent <agent>', 'Agent name')
    .action(async (id, options) => {
      const serverUrl = getServerUrl(program);
      if (serverUrl) {
        await remoteClient.claimTask(serverUrl, id, options.agent);
        console.log(`${id} claimed by ${options.agent}.`);
      } else {
        taskClaim(cwd, id, options.agent);
      }
    });
  taskCmd
    .command('done <id>')
    .description('Mark a task as done')
    .action(async (id) => {
      const serverUrl = getServerUrl(program);
      if (serverUrl) {
        await remoteClient.doneTask(serverUrl, id);
        console.log(`${id} marked as done.`);
      } else {
        taskDone(cwd, id);
      }
    });
  program.addCommand(taskCmd);

  const handoffCmd = new Command('handoff').description('Manage handoffs');
  handoffCmd
    .command('create')
    .description('Create a handoff')
    .option('--fromRole <role>', 'From role')
    .option('--toRole <role>', 'To role')
    .option('--taskId <id>', 'Related task id')
    .option('--summary <summary>', 'Summary')
    .option('--context <context>', 'Context')
    .action(async (options) => {
      const serverUrl = getServerUrl(program);
      if (serverUrl) {
        const handoff = await remoteClient.createHandoff(serverUrl, options);
        console.log(`Handoff created: ${handoff.id}`);
      } else {
        await handoffCreate(cwd, options);
      }
    });
  handoffCmd
    .command('read <id>')
    .description('Read a handoff')
    .action(async (id) => {
      const serverUrl = getServerUrl(program);
      if (serverUrl) {
        const { handoff, body } = await remoteClient.getHandoff(serverUrl, id);
        console.log(`# ${handoff.summary}`);
        console.log(`From: ${handoff.fromRole}${handoff.toRole}`);
        if (handoff.taskId) console.log(`Task: ${handoff.taskId}`);
        console.log('\n' + body);
      } else {
        handoffRead(cwd, id);
      }
    });
  handoffCmd
    .command('list')
    .description('List handoffs')
    .action(async () => {
      const serverUrl = getServerUrl(program);
      if (serverUrl) {
        const handoffs = await remoteClient.listHandoffs(serverUrl);
        if (handoffs.length === 0) { console.log('No handoffs found.'); return; }
        for (const h of handoffs) console.log(`${h.id}: ${h.title}`);
      } else {
        handoffList(cwd);
      }
    });
  program.addCommand(handoffCmd);

  const decisionCmd = new Command('decision').description('Manage decisions');
  decisionCmd
    .command('create')
    .description('Create a decision record')
    .option('--title <title>', 'Title')
    .option('--context <context>', 'Context')
    .option('--decision <decision>', 'Decision')
    .action(async (options) => {
      const serverUrl = getServerUrl(program);
      if (serverUrl) {
        const decision = await remoteClient.createDecision(serverUrl, options);
        console.log(`Decision recorded: ${decision.id}`);
      } else {
        await decisionCreate(cwd, options);
      }
    });
  decisionCmd
    .command('list')
    .description('List decisions')
    .action(async () => {
      const serverUrl = getServerUrl(program);
      if (serverUrl) {
        const decisions = await remoteClient.listDecisions(serverUrl);
        if (decisions.length === 0) { console.log('No decisions found.'); return; }
        for (const d of decisions) console.log(`${d.id}: ${d.title}`);
      } else {
        decisionList(cwd);
      }
    });
  program.addCommand(decisionCmd);

  program
    .command('delegate')
    .description('Suggest or auto-delegate open tasks')
    .option('--auto', 'Create handoff automatically')
    .action(async (options) => {
      const serverUrl = getServerUrl(program);
      if (serverUrl) {
        const result = await remoteClient.delegate(serverUrl, options.auto ?? false);
        if (!result.suggestion) { console.log('No open tasks to delegate.'); return; }
        const s = result.suggestion;
        console.log('Suggested delegation:');
        console.log(`  Task: ${s.task.id}${s.task.title}`);
        console.log(`  Role: ${s.role}`);
        console.log(`  Preferred agent: ${s.preferredAgent}`);
        if (result.handoff) console.log('Handoff created automatically.');
        else console.log('Run with --auto to create the handoff.');
      } else {
        await delegate(cwd, options);
      }
    });

  const serverCmd = new Command('server').description('Optional local API server');
  serverCmd
    .command('start')
    .description('Start the optional AgentHub API server')
    .option('-p, --port <port>', 'Port', '3377')
    .option('-h, --host <host>', 'Host to bind to', '127.0.0.1')
    .action((options) => serverStart(cwd, { port: parseInt(options.port, 10), host: options.host }));
  program.addCommand(serverCmd);

  return program;
}
  • Step 2: Run full test suite
pnpm test

Expected: PASS (existing tests still work; remote wiring not yet exercised by tests).

  • Step 3: Commit
git add src/cli/index.ts
git commit -m "feat(cli): add --server / AGENTHUB_SERVER remote mode wiring"

Task 11: Add --host option to server start

Files:

  • Modify: src/server/index.ts

  • Modify: src/cli/commands/server.ts

  • Step 1: Update server/index.ts

import Fastify from 'fastify';
import { registerRoutes } from './routes.js';

export function buildApp(cwd: string) {
  const app = Fastify({ logger: false });
  registerRoutes(app, cwd);
  return app;
}

export async function startServer(cwd: string, options: { port?: number; host?: string } = {}): Promise<{ app: Fastify.FastifyInstance; url: string }> {
  const app = buildApp(cwd);
  const port = options.port ?? 3377;
  const host = options.host ?? '127.0.0.1';

  try {
    await app.listen({ port, host });
    const address = app.server.address();
    const url = typeof address === 'string' ? address : `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
    console.log(`AgentHub server listening on http://${host}:${port}`);
    return { app, url };
  } catch (err) {
    app.log.error(err);
    process.exit(1);
  }
}
  • Step 2: Update server.ts CLI command
import { startServer } from '../../server/index.js';

export async function serverStart(cwd: string, options: { port: number; host: string }): Promise<void> {
  await startServer(cwd, options);
}
  • Step 3: Run tests and commit
pnpm test
git add src/server/index.ts src/cli/commands/server.ts
git commit -m "feat(server): support --host for LAN binding and export buildApp"

Task 12: Extend server routes

Files:

  • Modify: src/server/routes.ts

  • Step 1: Replace routes.ts with full CRUD

import { FastifyInstance } from 'fastify';
import { createTask, listTasks, getTask, claimTask, doneTask } from '../core/services/taskService.js';
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
import { createDecision, listDecisions } from '../core/services/decisionService.js';
import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js';
import { getStatus, updateStatus } from '../core/services/statusService.js';
import { autoDelegate, suggestDelegation } from '../core/services/delegateService.js';
import type { Task, Handoff, Decision, Memory } from '../core/schema.js';

export async function registerRoutes(app: FastifyInstance, cwd: string): Promise<void> {
  app.get('/status', async () => ({ body: getStatus(cwd) }));
  app.post('/status/update', async () => ({ body: updateStatus(cwd) }));

  app.get('/tasks', async (request) => {
    const { status, role } = request.query as { status?: string; role?: string };
    return listTasks(cwd, { status, role });
  });

  app.post('/tasks', async (request) => {
    return createTask(cwd, request.body as Partial<Task>);
  });

  app.get('/tasks/:id', async (request) => {
    const { id } = request.params as { id: string };
    const { task, body } = getTask(cwd, id);
    return { task, body };
  });

  app.patch('/tasks/:id', async (request) => {
    const { id } = request.params as { id: string };
    const patch = request.body as Partial<Task>;
    if (patch.status === 'in_progress' && patch.assignedTo) {
      return claimTask(cwd, id, patch.assignedTo);
    }
    if (patch.status === 'done') {
      return doneTask(cwd, id);
    }
    return { error: 'Unsupported patch' };
  });

  app.get('/handoffs', async () => listHandoffs(cwd));
  app.post('/handoffs', async (request) => createHandoff(cwd, request.body as Partial<Handoff>));
  app.get('/handoffs/:id', async (request) => {
    const { id } = request.params as { id: string };
    const { handoff, body } = getHandoff(cwd, id);
    return { handoff, body };
  });

  app.get('/decisions', async () => listDecisions(cwd));
  app.post('/decisions', async (request) => createDecision(cwd, request.body as Partial<Decision>));

  app.get('/memory', async () => listMemory(cwd));
  app.post('/memory', async (request) => addMemory(cwd, request.body as Partial<Memory>));
  app.get('/memory/search', async (request) => {
    const { q } = request.query as { q: string };
    return searchMemory(cwd, q ?? '');
  });

  app.post('/delegate', async (request) => {
    const { auto } = request.query as { auto?: string };
    const suggestion = suggestDelegation(cwd);
    if (!suggestion) return { suggestion: null };
    if (auto === 'true') {
      const handoff = autoDelegate(cwd);
      return { suggestion, handoff };
    }
    return { suggestion };
  });
}
  • Step 2: Run tests and commit
pnpm test
git add src/server/routes.ts
git commit -m "feat(server): add full CRUD endpoints for network mode"

Task 13: Add server route tests

Files:

  • Create: tests/server.test.ts

  • Step 1: Write server tests

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { buildApp } from '../src/server/index.js';

describe('server routes', () => {
  let cwd: string;
  let app: ReturnType<typeof buildApp>;

  beforeEach(() => {
    cwd = mkdtempSync(join(tmpdir(), 'ah-server-'));
    app = buildApp(cwd);
  });

  afterEach(() => {
    rmSync(cwd, { recursive: true, force: true });
  });

  it('creates a task via POST /tasks', async () => {
    const res = await app.inject({
      method: 'POST',
      url: '/tasks',
      payload: { title: 'API task', role: 'implementer' },
    });
    expect(res.statusCode).toBe(200);
    const task = JSON.parse(res.payload);
    expect(task.id).toBe('TSK-0001');
  });

  it('lists tasks via GET /tasks', async () => {
    await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
    const res = await app.inject({ method: 'GET', url: '/tasks' });
    expect(JSON.parse(res.payload)).toHaveLength(1);
  });

  it('updates status via POST /status/update', async () => {
    await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
    const res = await app.inject({ method: 'POST', url: '/status/update' });
    expect(res.statusCode).toBe(200);
    expect(JSON.parse(res.payload).body).toContain('Active tasks: 1');
  });
});
  • Step 2: Run tests and commit
pnpm test tests/server.test.ts
git add tests/server.test.ts
git commit -m "test(server): add route tests"

Task 14: Add remote E2E test

Files:

  • Create: tests/e2e-network.test.ts

  • Step 1: Write E2E test

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { startServer } from '../src/server/index.js';
import { remoteClient } from '../src/cli/remoteClient.js';
import { init } from '../src/cli/commands/init.js';

describe('network e2e', () => {
  let cwd: string;
  let server: Awaited<ReturnType<typeof startServer>>;

  beforeEach(async () => {
    cwd = mkdtempSync(join(tmpdir(), 'ah-net-'));
    init(cwd, { projectName: 'net-test', yes: true });
    server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
  });

  afterEach(async () => {
    await server.app.close();
    rmSync(cwd, { recursive: true, force: true });
  });

  it('creates a task remotely and reads it back', async () => {
    const url = server.url;
    const created = await remoteClient.createTask(url, { title: 'Remote', role: 'implementer' });
    expect(created.id).toBe('TSK-0001');

    const tasks = await remoteClient.listTasks(url);
    expect(tasks).toHaveLength(1);

    const { task } = await remoteClient.getTask(url, 'TSK-0001');
    expect(task.title).toBe('Remote');
  });
});
  • Step 2: Run tests and commit
pnpm test tests/e2e-network.test.ts
git add tests/e2e-network.test.ts
git commit -m "test(e2e): add network mode end-to-end test"

Task 15: Update README

Files:

  • Modify: README.md

  • Step 1: Add network mode section to README.md

Append:

## Network Mode

AgentHub can expose a project to other machines on the same network.

On the host machine (e.g. Mac):

\`\`\`bash
cd my-project
agenthub init
agenthub server start --host 0.0.0.0 --port 3377
\`\`\`

On another machine (e.g. Windows):

\`\`\`powershell
$env:AGENTHUB_SERVER="http://<mac-ip>:3377"
agenthub task create --title "Windows task" --role implementer
agenthub status
\`\`\`

Use `--server http://<ip>:3377` on each command instead of the environment variable if you prefer.

`init` always runs locally on the host machine.
  • Step 2: Commit
git add README.md
git commit -m "docs(readme): document network mode"

Task 16: Final verification

  • Step 1: Run full test suite
pnpm test
pnpm build

Expected: All tests pass, TypeScript compiles cleanly.

  • Step 2: Push to Gitea
git push origin main

Self-Review Checklist

  1. Spec coverage:
    • --server / AGENTHUB_SERVER → Task 10
    • --host for LAN → Task 11
    • Full CRUD endpoints → Task 12
    • RemoteClient → Task 9
    • Service extraction → Tasks 1-8
    • Tests → Tasks 13-14
    • README → Task 15
  2. Placeholder scan: No TBD/TODO placeholders; all code shown.
  3. Type consistency: IndexEntry is reused from src/core/index.ts; service return types use ReturnType<Index['list']>.