# 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** ```typescript 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** ```bash cd /Users/chahinebrini/mono/agenthub pnpm test tests/taskService.test.ts ``` Expected: FAIL (module not found). - [ ] **Step 3: Implement taskService.ts** ```typescript 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 { 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 { 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 { 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** ```bash pnpm test tests/taskService.test.ts ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash 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** ```typescript 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 = {}): Promise { 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** ```bash pnpm test tests/task-cmd.test.ts ``` Expected: PASS. - [ ] **Step 3: Commit** ```bash 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** ```typescript 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** ```typescript 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 { 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 { 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** ```bash 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** ```typescript 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 = {}): Promise { 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** ```bash 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** ```typescript 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 { 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 { 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** ```typescript 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 = {}): Promise { 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** ```bash 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** ```typescript 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 { 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 { const index = new Index(cwd); const results = index.search(query); index.close(); return results; } export function listMemory(cwd: string): ReturnType { const index = new Index(cwd); const memories = index.list('memory'); index.close(); return memories; } ``` - [ ] **Step 2: Refactor memory.ts CLI command** ```typescript 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 = {}): Promise { 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** ```bash 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** ```typescript 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** ```typescript 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** ```bash 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** ```typescript 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** ```typescript import { suggestDelegation, autoDelegate } from '../../core/services/delegateService.js'; export async function delegate(cwd: string, options: { auto?: boolean } = {}): Promise { 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** ```bash 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** ```typescript 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(baseUrl: string, method: string, path: string, body?: unknown): Promise { 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 { return request<{ body: string }>(baseUrl, 'GET', '/status').then((r) => r.body); }, async updateStatus(baseUrl: string): Promise { return request<{ body: string }>(baseUrl, 'POST', '/status/update').then((r) => r.body); }, async createTask(baseUrl: string, options: Partial): Promise { return request(baseUrl, 'POST', '/tasks', options); }, async listTasks(baseUrl: string, filters?: { status?: string; role?: string }): Promise { const params = new URLSearchParams(filters as Record); return request(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 { return request(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName }); }, async doneTask(baseUrl: string, id: string): Promise { return request(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'done' }); }, async createHandoff(baseUrl: string, options: Partial): Promise { return request(baseUrl, 'POST', '/handoffs', options); }, async listHandoffs(baseUrl: string): Promise { return request(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): Promise { return request(baseUrl, 'POST', '/decisions', options); }, async listDecisions(baseUrl: string): Promise { return request(baseUrl, 'GET', '/decisions'); }, async addMemory(baseUrl: string, options: Partial): Promise { return request(baseUrl, 'POST', '/memory', options); }, async searchMemory(baseUrl: string, query: string): Promise { return request(baseUrl, 'GET', `/memory/search?q=${encodeURIComponent(query)}`); }, async listMemory(baseUrl: string): Promise { return request(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** ```typescript 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** ```bash 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. ```typescript 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 ', 'AgentHub server URL (env: AGENTHUB_SERVER)'); program .command('init') .description('Initialize AgentHub in the current directory') .option('-n, --project-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') .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** ```bash pnpm test ``` Expected: PASS (existing tests still work; remote wiring not yet exercised by tests). - [ ] **Step 3: Commit** ```bash 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** ```typescript 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** ```typescript 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** ```bash 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** ```typescript 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** ```bash 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** ```typescript 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** ```bash 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** ```typescript 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** ```bash 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: ```markdown ## 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** ```bash git add README.md git commit -m "docs(readme): document network mode" ``` --- ## Task 16: Final verification - [ ] **Step 1: Run full test suite** ```bash pnpm test pnpm build ``` Expected: All tests pass, TypeScript compiles cleanly. - [ ] **Step 2: Push to Gitea** ```bash 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']>`.