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.mdgoverns; supervisor library insrc/gains NO new dependencies and is not modified except where a task explicitly says so. - Auth: mandatory
X056_TOKENenv (min 24 chars — server refuses to boot otherwise),Authorization: Bearer <token>on/api/*; SSE also accepts?token=. Constant-time comparison./healthzandGET /(panel) are unauthenticated; the panel contains no secrets. - One live session at a time: starting/continuing while running → HTTP 409
{error: 'busy'}. - Session
cwdmust resolve (realpath) insideX056_WORKSPACE_ROOT; violations → HTTP 400. - Spawned sessions keep the v1 contract:
--dangerously-skip-permissions, per-accountCLAUDE_CONFIG_DIR,X056_CLAUDE_PATHoverride respected (tests usetest/bin/fake-claude). - Ports: gateway listens on
PORT(default 4056), compose maps4056: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.
.envis gitignored. - Every created/modified
.mdgets 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.htmlcontaining<!-- x056 panel placeholder -->x056so 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;createAppsignature matches test usage;EmittingLog.appendoverride matchesEventLog.appendsignature (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.