diff --git a/docs/plans/2026-06-25-agenthub-network-mode.md b/docs/plans/2026-06-25-agenthub-network-mode.md new file mode 100644 index 0000000..6174bc8 --- /dev/null +++ b/docs/plans/2026-06-25-agenthub-network-mode.md @@ -0,0 +1,1578 @@ +# 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']>`.