think
16px
820px

X056 Gateway v1.5 (Remote Access + Docker Compose) 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: Browser-accessible remote control over the proven failover supervisor, deployed as one Docker Compose service on this server.

Architecture: NestJS gateway (server/) wraps the dependency-free supervisor library (src/): a SessionManager runs one runSession at a time, fans events out to SSE subscribers through a ring buffer, and a static panel page consumes it. Docker image bundles Node + the claude CLI; host config dirs and the workspace root are bind-mounted at identical paths so the shared projects/ symlink and file ownership keep working.

Tech Stack: NestJS 11 (+ reflect-metadata, rxjs), tsx runtime (no build step), vanilla-JS static panel, node:22-bookworm image, Docker Compose v2.

Global Constraints

  • Spec §7a of docs/superpowers/specs/2026-07-04-max-failover-design.md governs; supervisor library in src/ gains NO new dependencies and is not modified except where a task explicitly says so.
  • Auth: mandatory X056_TOKEN env (min 24 chars — server refuses to boot otherwise), Authorization: Bearer <token> on /api/*; SSE also accepts ?token=. Constant-time comparison. /healthz and GET / (panel) are unauthenticated; the panel contains no secrets.
  • One live session at a time: starting/continuing while running → HTTP 409 {error: 'busy'}.
  • Session cwd must resolve (realpath) inside X056_WORKSPACE_ROOT; violations → HTTP 400.
  • Spawned sessions keep the v1 contract: --dangerously-skip-permissions, per-account CLAUDE_CONFIG_DIR, X056_CLAUDE_PATH override respected (tests use test/bin/fake-claude).
  • Ports: gateway listens on PORT (default 4056), compose maps 4056:4056.
  • Container runs as uid 1001; mounts: /home/efran/.claude-x056-a, /home/efran/.claude-x056-b, /home/efran/remote-development (identical container paths), named volume at /app/state.
  • Never log or serve OAuth tokens. .env is gitignored.
  • Every created/modified .md gets uploaded per CLAUDE.md.

File Structure

server/manager.ts            Task 1  SessionManager + EmittingLog + ring buffer
test/manager.test.ts         Task 1
server/auth.guard.ts         Task 2  bearer/query token guard
server/api.controller.ts     Task 2  REST + SSE endpoints
server/app.module.ts         Task 2
server/main.ts               Task 2  bootstrap (exported createApp for tests)
test/gateway.test.ts         Task 2  HTTP e2e over fake-claude
server/public/panel.html     Task 3  static panel
Dockerfile, compose.yaml,
.dockerignore, docs/ACCESS.md Task 4
(deploy + smoke)             Task 5

Task 1: SessionManager

Files:
- Create: server/manager.ts
- Test: test/manager.test.ts

Interfaces:
- Consumes: runSession, RunSessionOptions, SessionResult from ../src/failover.js; AccountRegistry from ../src/accounts.js; EventLog from ../src/eventlog.js; RawEvent from ../src/types.js.
- Produces (Task 2 relies on these exact shapes):
- GatewayEvent = { seq: number; ts: string; kind: string; data: Record<string, unknown> }
- class BusyError extends Error
- SessionManagerOptions = { stateDir: string; workspaceRoot: string; claudePath?: string; runSessionFn?: typeof runSession }
- class SessionManager:
- start(prompt: string, cwd?: string): string — new session id; BusyError if running; invalid cwd → Error('cwd outside workspace root')
- continueLast(prompt: string): string — resumes lastSessionId (Error 'no previous session' if none); BusyError if running
- subscribe(fn: (e: GatewayEvent) => void, sinceSeq?: number): () => void — replays buffered events with seq > sinceSeq, then live; returns unsubscribe
- snapshot(): { running: boolean; currentSessionId: string | null; lastSessionId: string | null; lastResult: SessionResult | null }
- forceSwitch(): boolean — SIGUSR1 to own process when running; false when idle
- ring buffer capped at 1000 events

  • [ ] Step 1: Write the failing test

test/manager.test.ts:

import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { AccountRegistry } from '../src/accounts.js';
import type { RunSessionOptions, SessionResult } from '../src/failover.js';
import { BusyError, SessionManager, type GatewayEvent } from '../server/manager.js';

function fixture(result: SessionResult, opts?: { emitEvents?: boolean; delayMs?: number }) {
  const dir = mkdtempSync(join(tmpdir(), 'x056-mgr-'));
  const stateDir = join(dir, 'state');
  mkdirSync(stateDir, { recursive: true });
  AccountRegistry.init(join(stateDir, 'accounts.json'), [
    { name: 'a', configDir: '/cfg/a' },
    { name: 'b', configDir: '/cfg/b' },
  ]);
  const calls: RunSessionOptions[] = [];
  const runSessionFn = (async (o: RunSessionOptions) => {
    calls.push(o);
    if (opts?.emitEvents !== false) {
      o.tap?.({ type: 'assistant', message: { content: [{ type: 'text', text: 'hello from claude' }] } });
      o.log.append({ type: 'failover', sessionId: o.sessionId, from: 'a' });
    }
    await new Promise((r) => setTimeout(r, opts?.delayMs ?? 20));
    return result;
  }) as unknown as typeof import('../src/failover.js').runSession;
  const mgr = new SessionManager({ stateDir, workspaceRoot: dir, runSessionFn });
  return { mgr, calls, dir, stateDir };
}

const COMPLETED: SessionResult = { status: 'completed', finalAccount: 'b', failovers: 1, resultText: 'done' };

async function waitFor(pred: () => boolean, ms = 2000): Promise<void> {
  const t0 = Date.now();
  while (!pred()) {
    if (Date.now() - t0 > ms) throw new Error('waitFor timeout');
    await new Promise((r) => setTimeout(r, 10));
  }
}

describe('SessionManager', () => {
  it('runs a session, emits assistant text + log + lifecycle events, persists state', async () => {
    const { mgr, calls, dir, stateDir } = fixture(COMPLETED);
    const seen: GatewayEvent[] = [];
    mgr.subscribe((e) => seen.push(e));
    const sid = mgr.start('do it');
    expect(calls[0]?.prompt ?? '').toBe('do it');
    await waitFor(() => mgr.snapshot().running === false && seen.some((e) => e.kind === 'session_done'));
    const kinds = seen.map((e) => e.kind);
    expect(kinds).toEqual(expect.arrayContaining(['session_started', 'assistant_text', 'supervisor', 'session_done']));
    expect(seen.find((e) => e.kind === 'assistant_text')?.data.text).toBe('hello from claude');
    expect(seen.find((e) => e.kind === 'supervisor')?.data.type).toBe('failover');
    const st = JSON.parse(readFileSync(join(stateDir, 'state.json'), 'utf8'));
    expect(st.lastSessionId).toBe(sid);
    expect(st.cwd).toBe(dir);
    expect(mgr.snapshot().lastResult).toEqual(COMPLETED);
  });

  it('rejects concurrent starts with BusyError and allows continue after completion', async () => {
    const { mgr, calls } = fixture(COMPLETED, { delayMs: 100 });
    mgr.start('first');
    expect(() => mgr.start('second')).toThrow(BusyError);
    await waitFor(() => mgr.snapshot().running === false);
    const sid2 = mgr.continueLast('again');
    expect(sid2).toBe(mgr.snapshot().currentSessionId ?? sid2);
    await waitFor(() => mgr.snapshot().running === false);
    expect(calls[1]?.resume).toBe(true);
    expect(calls[1]?.sessionId).toBe(calls[0]?.sessionId);
  });

  it('continueLast without prior session throws', () => {
    const { mgr } = fixture(COMPLETED);
    expect(() => mgr.continueLast('x')).toThrow(/no previous session/);
  });

  it('rejects cwd outside workspace root', () => {
    const { mgr } = fixture(COMPLETED);
    expect(() => mgr.start('x', '/etc')).toThrow(/outside workspace root/);
  });

  it('replays buffered events to late subscribers from sinceSeq', async () => {
    const { mgr } = fixture(COMPLETED);
    mgr.start('task');
    await waitFor(() => mgr.snapshot().running === false);
    const all: GatewayEvent[] = [];
    mgr.subscribe((e) => all.push(e));
    expect(all.length).toBeGreaterThanOrEqual(3);
    const later: GatewayEvent[] = [];
    mgr.subscribe((e) => later.push(e), all[1].seq);
    expect(later[0].seq).toBe(all[2].seq);
  });

  it('forceSwitch returns false when idle', () => {
    const { mgr } = fixture(COMPLETED);
    expect(mgr.forceSwitch()).toBe(false);
  });
});
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run test/manager.test.ts
Expected: FAIL — cannot resolve ../server/manager.js.

  • [ ] Step 3: Implement

server/manager.ts:

import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { join, resolve, sep } from 'node:path';
import { AccountRegistry } from '../src/accounts.js';
import { EventLog } from '../src/eventlog.js';
import { runSession, type SessionResult } from '../src/failover.js';
import type { RawEvent } from '../src/types.js';

export interface GatewayEvent {
  seq: number;
  ts: string;
  kind: string;
  data: Record<string, unknown>;
}

export class BusyError extends Error {
  constructor() {
    super('a session is already running');
  }
}

export interface SessionManagerOptions {
  stateDir: string;
  workspaceRoot: string;
  claudePath?: string;
  runSessionFn?: typeof runSession;
}

const BUFFER_MAX = 1000;

/** EventLog that also forwards every supervisor row to the gateway stream. */
class EmittingLog extends EventLog {
  constructor(file: string, private readonly emit: (kind: string, data: Record<string, unknown>) => void) {
    super(file);
  }
  override append(event: Record<string, unknown>): void {
    super.append(event);
    this.emit('supervisor', event);
  }
}

interface PersistedState {
  lastSessionId?: string;
  cwd?: string;
}

export class SessionManager {
  private buffer: GatewayEvent[] = [];
  private seq = 0;
  private subscribers = new Set<(e: GatewayEvent) => void>();
  private running = false;
  private currentSessionId: string | null = null;
  private lastResult: SessionResult | null = null;

  constructor(private readonly opts: SessionManagerOptions) {
    mkdirSync(opts.stateDir, { recursive: true });
  }

  private get stateFile(): string {
    return join(this.opts.stateDir, 'state.json');
  }

  private loadState(): PersistedState {
    return existsSync(this.stateFile)
      ? (JSON.parse(readFileSync(this.stateFile, 'utf8')) as PersistedState)
      : {};
  }

  private emit(kind: string, data: Record<string, unknown>): void {
    const e: GatewayEvent = { seq: ++this.seq, ts: new Date().toISOString(), kind, data };
    this.buffer.push(e);
    if (this.buffer.length > BUFFER_MAX) this.buffer.splice(0, this.buffer.length - BUFFER_MAX);
    for (const fn of this.subscribers) {
      try {
        fn(e);
      } catch {
        // subscriber errors must not affect the session
      }
    }
  }

  private resolveCwd(cwd: string): string {
    const root = realpathSync(this.opts.workspaceRoot);
    const target = realpathSync(resolve(cwd));
    if (target !== root && !target.startsWith(root + sep)) {
      throw new Error(`cwd outside workspace root: ${cwd}`);
    }
    return target;
  }

  start(prompt: string, cwd?: string): string {
    if (this.running) throw new BusyError();
    const dir = this.resolveCwd(cwd ?? this.opts.workspaceRoot);
    const sessionId = randomUUID();
    this.launch(sessionId, prompt, dir, false);
    return sessionId;
  }

  continueLast(prompt: string): string {
    if (this.running) throw new BusyError();
    const st = this.loadState();
    if (!st.lastSessionId) throw new Error('no previous session — start one first');
    const dir = this.resolveCwd(st.cwd ?? this.opts.workspaceRoot);
    this.launch(st.lastSessionId, prompt, dir, true);
    return st.lastSessionId;
  }

  private launch(sessionId: string, prompt: string, cwd: string, resume: boolean): void {
    this.running = true;
    this.currentSessionId = sessionId;
    const runFn = this.opts.runSessionFn ?? runSession;
    const registry = AccountRegistry.load(join(this.opts.stateDir, 'accounts.json'));
    const log = new EmittingLog(join(this.opts.stateDir, 'events.jsonl'), (k, d) => this.emit(k, d));
    let stateSaved = resume;
    const saveStateOnce = () => {
      if (!stateSaved) {
        writeFileSync(this.stateFile, JSON.stringify({ lastSessionId: sessionId, cwd }));
        stateSaved = true;
      }
    };
    this.emit('session_started', { sessionId, cwd, resume, prompt });
    void runFn({
      registry,
      log,
      sessionId,
      cwd,
      prompt,
      resume,
      claudePath: this.opts.claudePath,
      tap: (e: RawEvent) => {
        saveStateOnce();
        this.tapToEvents(e);
      },
    })
      .then((res) => {
        this.lastResult = res;
        this.emit('session_done', { sessionId, ...res });
      })
      .catch((err: unknown) => {
        this.emit('session_error', { sessionId, message: (err as Error).message });
      })
      .finally(() => {
        this.running = false;
        this.currentSessionId = null;
      });
  }

  private tapToEvents(e: RawEvent): void {
    if (e.type !== 'assistant') return;
    const msg = e.message as { content?: unknown } | undefined;
    const content = Array.isArray(msg?.content) ? msg.content : [];
    for (const block of content) {
      if (block && typeof block === 'object' && (block as { type?: string }).type === 'text') {
        const text = (block as { text?: unknown }).text;
        if (typeof text === 'string' && text.length > 0) this.emit('assistant_text', { text });
      }
    }
  }

  subscribe(fn: (e: GatewayEvent) => void, sinceSeq = 0): () => void {
    for (const e of this.buffer) {
      if (e.seq > sinceSeq) fn(e);
    }
    this.subscribers.add(fn);
    return () => this.subscribers.delete(fn);
  }

  snapshot(): {
    running: boolean;
    currentSessionId: string | null;
    lastSessionId: string | null;
    lastResult: SessionResult | null;
  } {
    return {
      running: this.running,
      currentSessionId: this.currentSessionId,
      lastSessionId: this.loadState().lastSessionId ?? null,
      lastResult: this.lastResult,
    };
  }

  forceSwitch(): boolean {
    if (!this.running) return false;
    process.kill(process.pid, 'SIGUSR1');
    return true;
  }
}
  • [ ] Step 4: Run tests + typecheck, verify pass

Run: npx vitest run test/manager.test.ts && npm run typecheck
Expected: 6 tests PASS; tsc clean.

  • [ ] Step 5: Commit
git add server/manager.ts test/manager.test.ts
git commit -m "feat: add gateway session manager with event ring buffer"

Task 2: NestJS gateway + HTTP E2E

Files:
- Create: server/auth.guard.ts, server/api.controller.ts, server/app.module.ts, server/main.ts
- Modify: package.json (add deps + serve script), tsconfig.json (decorators)
- Test: test/gateway.test.ts

Interfaces:
- Consumes: SessionManager, BusyError, GatewayEvent (Task 1); AccountRegistry (src/accounts.js); fetchUsage (src/quota.js).
- Produces: createApp(opts: { token: string; stateDir: string; workspaceRoot: string; claudePath?: string }): Promise<INestApplication> from server/main.ts (also used by the test); booting with PORT=0 allowed. Routes per Global Constraints.

  • [ ] Step 1: Install deps and configure
npm install @nestjs/common@^11 @nestjs/core@^11 @nestjs/platform-express@^11 reflect-metadata@^0.2 rxjs@^7

tsconfig.json — add inside compilerOptions:

    "experimentalDecorators": true,
    "emitDecoratorMetadata": true

package.json — add script: "serve": "tsx server/main.ts".

  • [ ] Step 2: Write the failing E2E test

test/gateway.test.ts (boots the real app on an ephemeral port, drives it with fetch, uses fake-claude scenarios exactly like test/e2e.test.ts):

import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { INestApplication } from '@nestjs/common';
import { AccountRegistry } from '../src/accounts.js';
import { createApp } from '../server/main.js';

const FAKE = new URL('./bin/fake-claude', import.meta.url).pathname;
const TOKEN = 'test-token-0123456789abcdefghij';

let app: INestApplication;
let base = '';
let dir = '';

beforeAll(async () => {
  dir = mkdtempSync(join(tmpdir(), 'x056-gw-'));
  const stateDir = join(dir, 'state');
  mkdirSync(stateDir, { recursive: true });
  AccountRegistry.init(join(stateDir, 'accounts.json'), [
    { name: 'a', configDir: join(dir, 'cfg-a') },
    { name: 'b', configDir: join(dir, 'cfg-b') },
  ]);
  const scenarioA = join(dir, 'a.jsonl');
  writeFileSync(scenarioA, [
    JSON.stringify({ event: { type: 'system', subtype: 'init', session_id: 'gw' } }),
    JSON.stringify({ event: { type: 'assistant', message: { content: [{ type: 'text', text: 'working on it' }] } } }),
    JSON.stringify({ delayMs: 20 }),
    JSON.stringify({ event: { type: 'system', subtype: 'api_retry', attempt: 1, max_retries: 10, retry_delay_ms: 1000, error_status: 429, error: 'rate_limit' } }),
    JSON.stringify({ hang: true }),
  ].join('\n'));
  const scenarioB = join(dir, 'b.jsonl');
  writeFileSync(scenarioB, [
    JSON.stringify({ event: { type: 'assistant', message: { content: [{ type: 'text', text: 'finishing on b' }] } } }),
    JSON.stringify({ event: { type: 'result', subtype: 'success', is_error: false, api_error_status: null, result: 'done on b' } }),
    JSON.stringify({ exit: 0 }),
  ].join('\n'));
  process.env.X056_FAKE_SCENARIO = scenarioA;
  process.env.X056_FAKE_SCENARIO_RESUME = scenarioB;

  app = await createApp({ token: TOKEN, stateDir, workspaceRoot: dir, claudePath: FAKE });
  await app.listen(0);
  base = await app.getUrl();
});

afterAll(async () => {
  delete process.env.X056_FAKE_SCENARIO;
  delete process.env.X056_FAKE_SCENARIO_RESUME;
  await app?.close();
});

const auth = { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' };

describe('gateway e2e', () => {
  it('healthz is open, api is locked', async () => {
    expect((await fetch(`${base}/healthz`)).status).toBe(200);
    expect((await fetch(`${base}/api/accounts`)).status).toBe(401);
    expect((await fetch(`${base}/api/accounts`, { headers: { Authorization: 'Bearer wrong' } })).status).toBe(401);
  });

  it('serves the panel unauthenticated with no token inside', async () => {
    const res = await fetch(`${base}/`);
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain('x056');
    expect(html).not.toContain(TOKEN);
  });

  it('runs a full failover session over HTTP + SSE', async () => {
    const started = await fetch(`${base}/api/sessions`, { method: 'POST', headers: auth, body: JSON.stringify({ prompt: 'do the task' }) });
    expect(started.status).toBe(201);
    const { sessionId } = (await started.json()) as { sessionId: string };
    expect(sessionId).toMatch(/[0-9a-f-]{36}/);

    // busy guard
    const busy = await fetch(`${base}/api/sessions`, { method: 'POST', headers: auth, body: JSON.stringify({ prompt: 'nope' }) });
    expect(busy.status).toBe(409);

    // SSE via query token
    const stream = await fetch(`${base}/api/sessions/current/stream?token=${TOKEN}`);
    expect(stream.status).toBe(200);
    expect(stream.headers.get('content-type')).toContain('text/event-stream');
    const reader = stream.body!.getReader();
    const decoder = new TextDecoder();
    let text = '';
    const t0 = Date.now();
    while (!text.includes('session_done') && Date.now() - t0 < 15000) {
      const { value, done } = await reader.read();
      if (done) break;
      text += decoder.decode(value, { stream: true });
    }
    await reader.cancel().catch(() => {});
    expect(text).toContain('assistant_text');
    expect(text).toContain('working on it');
    expect(text).toContain('failover');
    expect(text).toContain('finishing on b');
    expect(text).toContain('session_done');
  }, 20000);

  it('accounts endpoint returns registry state with quota errors handled gracefully', async () => {
    const res = await fetch(`${base}/api/accounts`, { headers: auth });
    expect(res.status).toBe(200);
    const body = (await res.json()) as { name: string; state: unknown; quota: unknown; quotaError?: string }[];
    expect(body.map((a) => a.name)).toEqual(['a', 'b']);
    expect(body[0].quotaError).toBeTruthy(); // fake cfg dirs have no credentials
  });

  it('switch returns 409 when idle', async () => {
    const res = await fetch(`${base}/api/switch`, { method: 'POST', headers: auth });
    expect(res.status).toBe(409);
  });

  it('cwd outside workspace root is a 400', async () => {
    const res = await fetch(`${base}/api/sessions`, { method: 'POST', headers: auth, body: JSON.stringify({ prompt: 'x', cwd: '/etc' }) });
    expect(res.status).toBe(400);
  });
});
  • [ ] Step 3: Run test to verify it fails

Run: npx vitest run test/gateway.test.ts
Expected: FAIL — cannot resolve ../server/main.js.

  • [ ] Step 4: Implement

server/auth.guard.ts:

import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { createHash, timingSafeEqual } from 'node:crypto';
import type { Request } from 'express';

export const TOKEN_KEY = Symbol('x056-token');

function safeEqual(a: string, b: string): boolean {
  const ha = createHash('sha256').update(a).digest();
  const hb = createHash('sha256').update(b).digest();
  return timingSafeEqual(ha, hb);
}

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private readonly token: string) {}

  canActivate(ctx: ExecutionContext): boolean {
    const req = ctx.switchToHttp().getRequest<Request>();
    const header = req.headers.authorization ?? '';
    const bearer = header.startsWith('Bearer ') ? header.slice(7) : '';
    const query = typeof req.query.token === 'string' ? req.query.token : '';
    const presented = bearer || query;
    if (!presented || !safeEqual(presented, this.token)) throw new UnauthorizedException();
    return true;
  }
}

server/api.controller.ts:

import {
  BadRequestException,
  Body,
  ConflictException,
  Controller,
  Get,
  HttpCode,
  Post,
  Query,
  Res,
} from '@nestjs/common';
import type { Response } from 'express';
import { AccountRegistry } from '../src/accounts.js';
import { fetchUsage } from '../src/quota.js';
import { join } from 'node:path';
import { BusyError, SessionManager } from './manager.js';

@Controller('api')
export class ApiController {
  constructor(
    private readonly manager: SessionManager,
    private readonly stateDir: string,
  ) {}

  @Post('sessions')
  startSession(@Body() body: { prompt?: string; cwd?: string }): { sessionId: string } {
    if (!body?.prompt) throw new BadRequestException('prompt required');
    try {
      return { sessionId: this.manager.start(body.prompt, body.cwd) };
    } catch (err) {
      if (err instanceof BusyError) throw new ConflictException('busy');
      throw new BadRequestException((err as Error).message);
    }
  }

  @Post('sessions/current/messages')
  continueSession(@Body() body: { prompt?: string }): { sessionId: string } {
    if (!body?.prompt) throw new BadRequestException('prompt required');
    try {
      return { sessionId: this.manager.continueLast(body.prompt) };
    } catch (err) {
      if (err instanceof BusyError) throw new ConflictException('busy');
      throw new BadRequestException((err as Error).message);
    }
  }

  @Get('sessions/current/stream')
  stream(@Res() res: Response, @Query('since') since?: string): void {
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
    res.flushHeaders();
    const ping = setInterval(() => res.write(': ping\n\n'), 15000);
    const unsub = this.manager.subscribe((e) => {
      res.write(`id: ${e.seq}\nevent: ${e.kind}\ndata: ${JSON.stringify(e)}\n\n`);
    }, since ? Number(since) : 0);
    res.on('close', () => {
      clearInterval(ping);
      unsub();
    });
  }

  @Get('sessions')
  sessions(): unknown {
    return this.manager.snapshot();
  }

  @Get('accounts')
  async accounts(): Promise<unknown[]> {
    const registry = AccountRegistry.load(join(this.stateDir, 'accounts.json'));
    return Promise.all(
      registry.list().map(async (acct) => {
        try {
          return { ...acct, quota: await fetchUsage(acct.configDir) };
        } catch (err) {
          return { ...acct, quota: null, quotaError: (err as Error).message };
        }
      }),
    );
  }

  @Post('switch')
  @HttpCode(200)
  switch(): { switched: boolean } {
    if (!this.manager.forceSwitch()) throw new ConflictException('no session running');
    return { switched: true };
  }
}

server/app.module.ts:

import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './auth.guard.js';
import { ApiController } from './api.controller.js';
import { SessionManager } from './manager.js';

export interface GatewayConfig {
  token: string;
  stateDir: string;
  workspaceRoot: string;
  claudePath?: string;
}

export function buildModule(cfg: GatewayConfig): unknown {
  const manager = new SessionManager({
    stateDir: cfg.stateDir,
    workspaceRoot: cfg.workspaceRoot,
    claudePath: cfg.claudePath,
  });

  @Module({
    controllers: [ApiController],
    providers: [
      { provide: SessionManager, useValue: manager },
      { provide: ApiController, useFactory: () => new ApiController(manager, cfg.stateDir) },
      { provide: APP_GUARD, useFactory: () => new AuthGuard(cfg.token) },
    ],
  })
  class AppModule {}
  return AppModule;
}

Note: the APP_GUARD applies to controllers only; /healthz and / are wired in main.ts on the raw express instance BEFORE Nest routes, so they bypass the guard.

server/main.ts:

import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import type { INestApplication } from '@nestjs/common';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildModule, type GatewayConfig } from './app.module.js';

const __dir = dirname(fileURLToPath(import.meta.url));

export async function createApp(cfg: GatewayConfig): Promise<INestApplication> {
  if (!cfg.token || cfg.token.length < 24) {
    throw new Error('X056_TOKEN missing or shorter than 24 chars — refusing to start');
  }
  const app = await NestFactory.create(buildModule(cfg) as never, { logger: ['warn', 'error'] });
  const express = app.getHttpAdapter().getInstance() as import('express').Express;
  const panel = readFileSync(join(__dir, 'public', 'panel.html'), 'utf8');
  express.get('/healthz', (_req, res) => res.json({ ok: true }));
  express.get('/', (_req, res) => res.type('html').send(panel));
  return app;
}

const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMain) {
  const cfg: GatewayConfig = {
    token: process.env.X056_TOKEN ?? '',
    stateDir: process.env.X056_STATE_DIR ?? join(process.cwd(), 'state'),
    workspaceRoot: process.env.X056_WORKSPACE_ROOT ?? process.cwd(),
    claudePath: process.env.X056_CLAUDE_PATH,
  };
  const port = Number(process.env.PORT ?? 4056);
  createApp(cfg)
    .then((app) => app.listen(port, '0.0.0.0'))
    .then(() => console.log(`x056 gateway listening on :${port}`))
    .catch((err) => {
      console.error((err as Error).message);
      process.exit(1);
    });
}

Ordering caveat: registering express routes after NestFactory.create but before app.listen/app.init — if / or /healthz 404 because Nest initialized its router first, move registration to the adapter's use pre-middleware or register a Nest controller marked public via a @Public() metadata check in the guard instead. The implementer verifies which works on Nest 11 and notes it in the report; the E2E test pins the behavior either way.

  • [ ] Step 5: Placeholder panel (real one is Task 3 — create server/public/panel.html containing <!-- x056 panel placeholder -->x056 so main.ts boots)

  • [ ] Step 6: Run tests + typecheck

Run: npx vitest run test/gateway.test.ts && npm run typecheck && npm test
Expected: gateway 6 tests PASS; whole suite passes.

  • [ ] Step 7: Commit
git add package.json package-lock.json tsconfig.json server/ test/gateway.test.ts
git commit -m "feat: add nestjs gateway with sse streaming and token auth"

Task 3: Panel page

Files:
- Modify: server/public/panel.html (replace placeholder)

Interfaces: Consumes the HTTP API exactly as specified in Task 2. No build step — one self-contained HTML file, inline CSS/JS, no external resources.

  • [ ] Step 1: Implement the panel

server/public/panel.html — requirements (implementer writes the file; keep it lean, ~200 lines):
- Dark, minimal, mobile-friendly. Header: x056 remote control, connection dot, force-switch button.
- Token handling: read localStorage.x056_token; if absent prompt via window.prompt and store. A "⚙" button clears it. All fetches send Authorization: Bearer; the EventSource URL appends ?token=.
- Account cards: poll GET /api/accounts every 60 s + after every session_done; show name, email-less state badge (ok/limited until <local time>/unknown), 5h/7d utilization bars (green <70%, amber <90%, red ≥90%), quotaError shown as quota unavailable.
- Chat: EventSource('/api/sessions/current/stream?token=...'); render assistant_text as chat bubbles; supervisor events with type in (limit_detected,forced_switch,failover,parked,turn_failed,flap_guard_tripped) as inline system banners ("switching accounts…"); session_started/session_done as headers with status + account + failovers.
- Input box: textarea + Send. If snapshot.running (from GET /api/sessions) → POST to /api/sessions/current/messages… actually: Send always tries POST /api/sessions first if no lastSessionId, else /api/sessions/current/messages; on 409 show "busy — session still running". Optional "New session" toggle forces POST /api/sessions (with optional cwd field).
- Failover log pane (collapsible): last 50 supervisor events verbatim.
- On error of EventSource: reconnect with ?since=<last seq> after 2 s.

  • [ ] Step 2: Manual smoke via fake-claude
X056_TOKEN=devtoken-0123456789abcdefghij X056_CLAUDE_PATH=test/bin/fake-claude \
X056_FAKE_SCENARIO=/path/a.jsonl X056_FAKE_SCENARIO_RESUME=/path/b.jsonl npm run serve
# browse http://localhost:4056/ — enter token, run a task, watch failover banner appear

Capture what you verified in the report (page loads, chat streams, switch button 409s when idle, gauges render with quotaError).

  • [ ] Step 3: Re-run gateway e2e (panel must still contain 'x056', no token leak) + full suite; commit
npx vitest run test/gateway.test.ts && npm test
git add server/public/panel.html
git commit -m "feat: add static remote-control panel page"

Task 4: Dockerfile + Compose + docs

Files:
- Create: Dockerfile, compose.yaml, .dockerignore, docs/ACCESS.md
- Modify: .gitignore (add .env)

  • [ ] Step 1: Write the files

Dockerfile:

FROM node:22-bookworm
RUN npm install -g @anthropic-ai/claude-code \
 && apt-get update && apt-get install -y --no-install-recommends ripgrep curl \
 && rm -rf /var/lib/apt/lists/*
RUN useradd -m -u 1001 efran
USER efran
WORKDIR /app
COPY --chown=efran:efran package.json package-lock.json ./
RUN npm ci
COPY --chown=efran:efran . .
ENV PORT=4056
EXPOSE 4056
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD curl -fsS http://localhost:4056/healthz || exit 1
CMD ["npx", "tsx", "server/main.ts"]

compose.yaml:

services:
  x056:
    build: .
    restart: unless-stopped
    ports:
      - "4056:4056"
    environment:
      X056_TOKEN: ${X056_TOKEN:?generate one into .env — openssl rand -hex 32}
      X056_WORKSPACE_ROOT: /home/efran/remote-development
      X056_STATE_DIR: /app/state
      PORT: "4056"
    volumes:
      - /home/efran/.claude-x056-a:/home/efran/.claude-x056-a
      - /home/efran/.claude-x056-b:/home/efran/.claude-x056-b
      - /home/efran/remote-development:/home/efran/remote-development
      - x056-state:/app/state
volumes:
  x056-state:

.dockerignore:

node_modules
state
.git
.superpowers
docs
*.md

.gitignore — append .env.

docs/ACCESS.md — write: what the service is, docker compose up -d --build, generating .env (echo "X056_TOKEN=$(openssl rand -hex 32)" > .env), browsing http://<server-ip>:4056/ and pasting the token, the API surface (curl examples for sessions/messages/stream/accounts/switch), state locations (host config dirs, x056-state volume, transcripts under ~/.claude-x056-a/projects), security section: token is the only lock on a public port — recommended hardening: UFW allowlist or Tailscale (ports: "100.x.y.z:4056:4056"), token rotation = edit .env + docker compose up -d.

  • [ ] Step 2: Build + boot against fake-claude inside the container
echo "X056_TOKEN=$(openssl rand -hex 32)" > .env
docker compose build
docker compose up -d
sleep is hook-blocked in the driving shell: poll with until-loop
until curl -fsS localhost:4056/healthz; do sleep 2; done
TOKEN=$(grep -oP 'X056_TOKEN=\K.*' .env)
curl -fsS -H "Authorization: Bearer $TOKEN" localhost:4056/api/accounts

Expected: healthz {"ok":true}; accounts JSON shows both accounts WITH live quota (real credentials are mounted).

  • [ ] Step 3: Upload docs/ACCESS.md per CLAUDE.md; commit
curl -F "file=@docs/ACCESS.md" https://x056.think.val.id/upload
git add Dockerfile compose.yaml .dockerignore .gitignore docs/ACCESS.md
git commit -m "feat: dockerize gateway with compose deployment"

Task 5: Deployed smoke test (real end-to-end through the container)

No new files — a verification protocol; capture every command + output in the report.

  • [ ] Step 1: docker compose up -d --build (fresh); registry init inside the container state volume: docker compose exec x056 npx tsx src/cli.ts init (writes /app/state/accounts.json pointing at the mounted config dirs).
  • [ ] Step 2: From the HOST: curl -H "Authorization: Bearer $TOKEN" localhost:4056/api/accounts → both accounts with live 5h/7d numbers.
  • [ ] Step 3: Real tiny session through the deployed container:
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"prompt":"Reply with exactly: GATEWAY_OK","cwd":"/home/efran/remote-development/x056-remote-control"}' \
  localhost:4056/api/sessions
curl -N "localhost:4056/api/sessions/current/stream?token=$TOKEN" | head -40   # watch until session_done

Expected: assistant_text event containing GATEWAY_OK; session_done with status: "completed"; transcript file appears under ~/.claude-x056-a/projects/ on the HOST (bind mount proof).
- [ ] Step 4: Auth negative check from host: request without token → 401. Confirm the container survives a restart (docker compose restart x056, healthz green, /api/sessions shows lastSessionId persisted).
- [ ] Step 5: Commit nothing (no file changes) — append results to the task report only.


Self-Review (performed at plan time)

  • Spec §7a coverage: endpoints/auth/panel/docker/volumes/cwd-validation all mapped to Tasks 1-4; deployment proof = Task 5. One-live-session constraint in Task 1 (BusyError) + Task 2 (409). SIGUSR1 same-process switch in Task 1 forceSwitch.
  • Placeholders: Task 3 panel is specified by behavior contract rather than full HTML (deliberate: it is presentation code with an exact API contract and a manual-smoke gate; all APIs it consumes are fully coded in Task 2). Everything else is complete code.
  • Type consistency: GatewayEvent/BusyError/SessionManagerOptions (Task 1) match Task 2 imports; createApp signature matches test usage; EmittingLog.append override matches EventLog.append signature (Task 2 of v1 plan); fetchUsage(configDir) matches src/quota.ts.
  • Risk noted in-plan: Nest-vs-raw-express route ordering for unauthenticated / + /healthz (Task 2 Step 4 caveat) — E2E pins the required behavior, implementer picks the working mechanism.