Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
b31364a
refactor(gastown): consolidate control plane into TownDO, create Agen…
jrf0110 Feb 20, 2026
b391dfd
refactor(gastown): reroute all handlers to TownDO, delete Rig DO + Ma…
jrf0110 Feb 20, 2026
09fc4ac
refactor(gastown): replace subprocess agent management with SDK (#419)
jrf0110 Feb 20, 2026
c5fe185
refactor(gastown): WebSocket streaming + proactive startup + config-o…
jrf0110 Feb 20, 2026
fb2e430
test(gastown): update integration tests for TownDO (#419)
jrf0110 Feb 20, 2026
491fefb
fix(gastown): address PR review comments — patrol, staleThreshold, Pa…
jrf0110 Feb 20, 2026
5ad3f60
fix(gastown): symlink opencode->kilo in container, fix getMayorStatus…
jrf0110 Feb 20, 2026
86bad4a
fix(gastown): sendMayorMessage returns { agentId, sessionStatus } for…
jrf0110 Feb 20, 2026
738d863
fix(gastown): install both glibc and musl CLI binary variants in cont…
jrf0110 Feb 20, 2026
419d31f
fix(gastown): add kilocode_token to TownConfig, fall back from rig co…
jrf0110 Feb 20, 2026
67b91b7
fix(gastown): read kilocode_token from X-Town-Config in container, st…
jrf0110 Feb 20, 2026
b1fa808
fix(gastown): don't gate entire alarm on rig count, only proactive co…
jrf0110 Feb 20, 2026
4b3b9c0
debug(gastown): add diagnostic logging for KILOCODE_TOKEN flow (#419)
jrf0110 Feb 20, 2026
eceba00
debug(gastown): add logging to configureRig for token propagation tra…
jrf0110 Feb 20, 2026
2d97137
test(gastown): add E2E test harness and 5 passing tests (#419)
jrf0110 Feb 20, 2026
8d5abc9
test(gastown): 20 E2E tests passing + fix slingBead stale data, conta…
jrf0110 Feb 20, 2026
d5cc78c
fix(gastown): persist townId in KV, restore HTTP polling for events, …
jrf0110 Feb 21, 2026
bb67f50
fix(gastown): resolve token, proactive container start, WebSocket pol…
jrf0110 Feb 21, 2026
bf5fb87
refactor(gastown): WebSocket passthrough — no relay, no polling (#419)
jrf0110 Feb 21, 2026
a76eb88
debug(gastown): add logging to trace token flow through tRPC→gastown-…
jrf0110 Feb 21, 2026
2933fcb
fix(gastown): fix model ID parsing, event display, AgentDO persistenc…
jrf0110 Feb 21, 2026
a84f654
fix(gastown): use correct model IDs for kilo gateway (OpenRouter form…
jrf0110 Feb 21, 2026
456d86d
fix(gastown): persist KILOCODE_TOKEN on TownContainerDO for reliable …
jrf0110 Feb 21, 2026
ff4d4be
debug(gastown): add detailed logging to startAgentInContainer and sen…
jrf0110 Feb 21, 2026
5b88751
debug(gastown): log containerStatus on every sendMayorMessage call (#…
jrf0110 Feb 21, 2026
0363a47
fix(gastown): store kilocode_token on town creation, not just rig cre…
jrf0110 Feb 22, 2026
970246f
fix(gastown): register model in kilo provider config so follow-up mes…
jrf0110 Feb 22, 2026
55b3d55
fix(gastown): pass model on follow-up messages to prevent model-not-f…
jrf0110 Feb 22, 2026
7c99c51
fix(gastown): set GASTOWN_AGENT_ROLE in agent env so plugin detects m…
jrf0110 Feb 22, 2026
84fa2db
fix(gastown): store owner_user_id in town config so mayor JWT has val…
jrf0110 Feb 22, 2026
59ca00e
refactor(gastown): move all rig routes under /api/towns/:townId/rigs/…
jrf0110 Feb 22, 2026
b1cb1a9
fix(gastown): fix Zod schema mismatches for nullable fields and missi…
jrf0110 Feb 22, 2026
d1b0079
fix(gastown): use actual rigId in agent-events persistence path (#419)
jrf0110 Feb 22, 2026
db2e9a0
fix(gastown): parallel agent dispatch, rig_id on agents, completion r…
jrf0110 Feb 22, 2026
1d29dfd
debug(gastown): add logging to schedulePendingWork for agent dispatch…
jrf0110 Feb 22, 2026
2880905
fix(gastown): scope agents and beads to their rig via rig_id column (…
jrf0110 Feb 22, 2026
e882413
fix(gastown): address all PR review comments — 9 code fixes + 4 desig…
jrf0110 Feb 22, 2026
3187c7d
fix(gastown): update E2E tests for town-scoped routes, pass rig_id in…
jrf0110 Feb 22, 2026
5ffc5e0
fix(gastown): address 10 PR review comments — round 3 (#419)
jrf0110 Feb 22, 2026
c85d79b
fix(gastown): address 19 PR review comments — round 4 (#419)
jrf0110 Feb 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cloudflare-gastown/container/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ RUN apt-get update && \
# explicitly install the platform-specific binary package alongside the CLI.
# Also install @kilocode/plugin globally so repo-local tools (e.g.
# .opencode/tool/*.ts) can resolve it without a local node_modules.
RUN npm install -g @kilocode/cli @kilocode/cli-linux-x64 @kilocode/plugin
# Install both glibc and musl variants — the CLI's binary resolver may
# pick either depending on the detected libc.
RUN npm install -g @kilocode/cli @kilocode/cli-linux-x64 @kilocode/cli-linux-x64-musl @kilocode/plugin && \
ln -s "$(which kilo)" /usr/local/bin/opencode

# Create workspace directories
RUN mkdir -p /workspace/rigs /app
Expand Down
6 changes: 5 additions & 1 deletion cloudflare-gastown/container/Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ RUN apt-get update && \
# Install Kilo CLI globally via npm (needs real Node.js runtime).
# npm's global install does not resolve optionalDependencies, so we must
# explicitly install the platform-specific binary package alongside the CLI.
RUN npm install -g @kilocode/cli @kilocode/cli-linux-arm64
# Install both glibc and musl variants — the CLI's binary resolver may
# pick either depending on the detected libc. bun:1-slim is Debian (glibc)
# but the resolver sometimes misdetects; installing both is safe.
RUN npm install -g @kilocode/cli @kilocode/cli-linux-arm64 @kilocode/cli-linux-arm64-musl && \
ln -s "$(which kilo)" /usr/local/bin/opencode

# Create workspace directories
RUN mkdir -p /workspace/rigs /app
Expand Down
10 changes: 7 additions & 3 deletions cloudflare-gastown/container/plugin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,18 @@ export class GastownClient {
private token: string;
private agentId: string;
private rigId: string;
private townId: string;

constructor(env: GastownEnv) {
this.baseUrl = env.apiUrl.replace(/\/+$/, '');
this.token = env.sessionToken;
this.agentId = env.agentId;
this.rigId = env.rigId;
this.townId = env.townId;
}

private rigPath(path: string): string {
return `${this.baseUrl}/api/rigs/${this.rigId}${path}`;
return `${this.baseUrl}/api/towns/${this.townId}/rigs/${this.rigId}${path}`;
}

private agentPath(path: string): string {
Expand Down Expand Up @@ -294,18 +296,20 @@ export function createClientFromEnv(): GastownClient {
const sessionToken = process.env.GASTOWN_SESSION_TOKEN;
const agentId = process.env.GASTOWN_AGENT_ID;
const rigId = process.env.GASTOWN_RIG_ID;
const townId = process.env.GASTOWN_TOWN_ID;

if (!apiUrl || !sessionToken || !agentId || !rigId) {
if (!apiUrl || !sessionToken || !agentId || !rigId || !townId) {
const missing = [
!apiUrl && 'GASTOWN_API_URL',
!sessionToken && 'GASTOWN_SESSION_TOKEN',
!agentId && 'GASTOWN_AGENT_ID',
!rigId && 'GASTOWN_RIG_ID',
!townId && 'GASTOWN_TOWN_ID',
].filter(Boolean);
throw new Error(`Missing required Gastown environment variables: ${missing.join(', ')}`);
}

return new GastownClient({ apiUrl, sessionToken, agentId, rigId });
return new GastownClient({ apiUrl, sessionToken, agentId, rigId, townId });
}

export function createMayorClientFromEnv(): MayorGastownClient {
Expand Down
1 change: 1 addition & 0 deletions cloudflare-gastown/container/plugin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export type GastownEnv = {
sessionToken: string;
agentId: string;
rigId: string;
townId: string;
};

// Environment variable config for the mayor (town-scoped)
Expand Down
30 changes: 27 additions & 3 deletions cloudflare-gastown/container/src/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Config } from '@kilocode/sdk';
import { writeFile } from 'node:fs/promises';
import { cloneRepo, createWorktree } from './git-manager';
import { startAgent } from './process-manager';
import { getCurrentTownConfig } from './control-server';
import type { ManagedAgent, StartAgentRequest } from './types';

/**
Expand All @@ -18,22 +19,29 @@ function resolveEnv(request: StartAgentRequest, key: string): string | undefined
* the Kilo LLM gateway. Mirrors the pattern in cloud-agent-next's
* session-service.ts getSaferEnvVars().
*/
function buildKiloConfigContent(kilocodeToken: string): string {
function buildKiloConfigContent(kilocodeToken: string, model?: string): string {
const resolvedModel = model ?? 'anthropic/claude-sonnet-4.6';
return JSON.stringify({
provider: {
kilo: {
options: {
apiKey: kilocodeToken,
kilocodeToken,
},
// Explicitly register models so the kilo server doesn't reject them
// before routing to the gateway. The gateway handles actual validation.
models: {
[resolvedModel]: {},
'anthropic/claude-haiku-4.5': {},
},
},
},
// Override the small model (used for title generation) to a valid
// kilo-provider model. Without this, kilo serve defaults to
// openai/gpt-5-nano which doesn't exist in the kilo provider,
// causing ProviderModelNotFoundError that kills the entire prompt loop.
small_model: 'anthropic/claude-haiku-4.5',
model: 'anthropic/claude-sonnet-4.6',
model: resolvedModel,
// Override the title agent to use a valid model (same as small_model).
// kilo serve v1.0.23 resolves title model independently and the
// small_model fallback doesn't prevent ProviderModelNotFoundError.
Expand Down Expand Up @@ -108,6 +116,7 @@ function buildAgentEnv(request: StartAgentRequest): Record<string, string> {
GASTOWN_AGENT_ID: request.agentId,
GASTOWN_RIG_ID: request.rigId,
GASTOWN_TOWN_ID: request.townId,
GASTOWN_AGENT_ROLE: request.role,

GIT_AUTHOR_NAME: `${request.name} (gastown)`,
GIT_AUTHOR_EMAIL: `${request.name}@gastown.local`,
Expand All @@ -127,11 +136,26 @@ function buildAgentEnv(request: StartAgentRequest): Record<string, string> {
}
}

// Fall back to X-Town-Config for KILOCODE_TOKEN if not in request or process.env
if (!env.KILOCODE_TOKEN) {
const townConfig = getCurrentTownConfig();
const tokenFromConfig =
townConfig && typeof townConfig.kilocode_token === 'string'
? townConfig.kilocode_token
: undefined;
console.log(
`[buildAgentEnv] KILOCODE_TOKEN fallback: townConfig=${townConfig ? 'present' : 'null'} hasToken=${!!tokenFromConfig} requestEnvKeys=${Object.keys(request.envVars ?? {}).join(',')}`
);
if (tokenFromConfig) {
env.KILOCODE_TOKEN = tokenFromConfig;
}
}

// Build KILO_CONFIG_CONTENT so kilo serve can authenticate LLM calls.
// Must also set OPENCODE_CONFIG_CONTENT — kilo serve checks both names.
const kilocodeToken = env.KILOCODE_TOKEN;
if (kilocodeToken) {
const configJson = buildKiloConfigContent(kilocodeToken);
const configJson = buildKiloConfigContent(kilocodeToken, request.model);
env.KILO_CONFIG_CONTENT = configJson;
env.OPENCODE_CONFIG_CONTENT = configJson;
console.log(`[buildAgentEnv] KILO_CONFIG_CONTENT set (model=${JSON.parse(configJson).model})`);
Expand Down
2 changes: 1 addition & 1 deletion cloudflare-gastown/container/src/completion-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export async function reportAgentCompleted(

const url =
agent.completionCallbackUrl ??
`${apiUrl}/api/rigs/${agent.rigId}/agents/${agent.agentId}/completed`;
`${apiUrl}/api/towns/${agent.townId}/rigs/${agent.rigId}/agents/${agent.agentId}/completed`;
try {
const response = await fetch(url, {
method: 'POST',
Expand Down
136 changes: 133 additions & 3 deletions cloudflare-gastown/container/src/control-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getUptime,
stopAll,
getAgentEvents,
registerEventSink,
} from './process-manager';
import { startHeartbeat, stopHeartbeat } from './heartbeat';
import { mergeBranch } from './git-manager';
Expand All @@ -25,6 +26,32 @@ const streamTickets = new Map<string, { agentId: string; expiresAt: number }>();

export const app = new Hono();

// Apply town config from X-Town-Config header (sent by TownDO on every request)
let currentTownConfig: Record<string, unknown> | null = null;

/** Get the latest town config delivered via X-Town-Config header. */
export function getCurrentTownConfig(): Record<string, unknown> | null {
return currentTownConfig;
}

app.use('*', async (c, next) => {
const configHeader = c.req.header('X-Town-Config');
if (configHeader) {
try {
const parsed = JSON.parse(configHeader);
currentTownConfig = parsed;
const hasToken =
typeof parsed.kilocode_token === 'string' && parsed.kilocode_token.length > 0;
console.log(
`[control-server] X-Town-Config received: hasKilocodeToken=${hasToken} keys=${Object.keys(parsed).join(',')}`
);
} catch {
console.warn('[control-server] X-Town-Config header malformed');
}
}
await next();
});

// Log method, path, status, and duration for every request
app.use('*', async (c, next) => {
const start = performance.now();
Expand Down Expand Up @@ -261,7 +288,10 @@ app.onError((err, c) => {
});

/**
* Start the control server using Bun.serve + Hono.
* Start the control server using Bun.serve + Hono, with WebSocket support.
*
* The /ws endpoint provides a multiplexed event stream for all agents.
* SDK events from process-manager are forwarded to all connected WS clients.
*/
export function startControlServer(): void {
const PORT = 8080;
Expand All @@ -284,9 +314,109 @@ export function startControlServer(): void {
process.on('SIGTERM', () => void shutdown());
process.on('SIGINT', () => void shutdown());

Bun.serve({
// Track connected WebSocket clients with optional agent filter
type WSClient = import('bun').ServerWebSocket<{ agentId: string | null }>;
const wsClients = new Set<WSClient>();

// Agent stream URL patterns (the container receives the full path from the worker)
const AGENT_STREAM_RE = /\/agents\/([^/]+)\/stream$/;

// Register an event sink that forwards agent events to WS clients
registerEventSink((agentId, event, data) => {
const frame = JSON.stringify({
agentId,
event,
data,
timestamp: new Date().toISOString(),
});
for (const ws of wsClients) {
try {
// If the client subscribed to a specific agent, only send that agent's events
const filter = ws.data.agentId;
if (filter && filter !== agentId) continue;
ws.send(frame);
} catch {
wsClients.delete(ws);
}
}
});

Bun.serve<{ agentId: string | null }>({
port: PORT,
fetch: app.fetch,
fetch(req, server) {
const url = new URL(req.url);
const pathname = url.pathname;

// WebSocket upgrade: match /ws OR /agents/:id/stream (with any prefix)
const isWsUpgrade = req.headers.get('upgrade')?.toLowerCase() === 'websocket';
if (isWsUpgrade) {
let agentId: string | null = null;

if (pathname === '/ws') {
agentId = url.searchParams.get('agentId');
} else {
const match = pathname.match(AGENT_STREAM_RE);
if (match) agentId = match[1];
}

// Accept upgrade if the path matches any WS pattern
if (pathname === '/ws' || AGENT_STREAM_RE.test(pathname)) {
const upgraded = server.upgrade(req, { data: { agentId } });
if (upgraded) return undefined;
return new Response('WebSocket upgrade failed', { status: 400 });
}
}

// All other requests go through Hono
return app.fetch(req);
},
websocket: {
open(ws) {
wsClients.add(ws);
const agentFilter = ws.data.agentId ?? 'all';
console.log(
`[control-server] WebSocket connected: agent=${agentFilter} (${wsClients.size} total)`
);

// Send in-memory backfill for this session's events.
// This covers late-joining clients within the same container lifecycle.
// For historical events after container restarts, clients query the
// AgentDO via the worker's GET /agents/:id/events endpoint.
if (ws.data.agentId) {
const events = getAgentEvents(ws.data.agentId, 0);
for (const evt of events) {
try {
ws.send(
JSON.stringify({
agentId: ws.data.agentId,
event: evt.event,
data: evt.data,
timestamp: evt.timestamp,
})
);
} catch {
break;
}
}
}
},
message(ws, message) {
// Handle subscribe messages from client
try {
const msg = JSON.parse(String(message));
if (msg.type === 'subscribe' && msg.agentId) {
ws.data.agentId = msg.agentId;
console.log(`[control-server] WebSocket subscribed to agent=${msg.agentId}`);
}
} catch {
// Ignore
}
},
close(ws) {
wsClients.delete(ws);
console.log(`[control-server] WebSocket disconnected (${wsClients.size} total)`);
},
},
});

console.log(`Town container control server listening on port ${PORT}`);
Expand Down
Loading