Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions cloudflare-gastown/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.dev.vars
container/dist/
dist-types/
2 changes: 2 additions & 0 deletions cloudflare-gastown/container/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
.wrangler
5 changes: 5 additions & 0 deletions cloudflare-gastown/container/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Generated by scripts/prepare-container.mjs for Docker builds.
# pnpm needs the workspace yaml and lockfile to resolve catalog: references.
pnpm-workspace.yaml
pnpm-lock.yaml
package.prod.json
14 changes: 11 additions & 3 deletions cloudflare-gastown/container/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,23 @@ RUN cd /opt/gastown-plugin && npm install --omit=dev && \

WORKDIR /app

# Copy package files and install deps deterministically
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
# ── Install production deps via pnpm ────────────────────────────────
# package.json uses pnpm catalog: references for shared versions.
# pnpm-workspace.yaml (catalog only) and pnpm-lock.yaml are copied
# from the monorepo root by `pnpm container:prepare`.
# package.prod.json is package.json with workspace: devDeps removed.
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
COPY package.prod.json package.json
RUN pnpm install --prod

# Copy source (bun runs TypeScript directly — no build step needed)
COPY src/ ./src/

RUN chown -R agent:agent /app

# Explicitly set HOME so kilo resolves XDG paths correctly.
# Some container runtimes don't set HOME when switching USER.
ENV HOME=/home/agent
USER agent

EXPOSE 8080
Expand Down
14 changes: 11 additions & 3 deletions cloudflare-gastown/container/Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,23 @@ RUN cd /opt/gastown-plugin && npm install --omit=dev && \

WORKDIR /app

# Copy package files and install deps deterministically
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
# ── Install production deps via pnpm ────────────────────────────────
# package.json uses pnpm catalog: references for shared versions.
# pnpm-workspace.yaml (catalog only) and pnpm-lock.yaml are copied
# from the monorepo root by `pnpm container:prepare`.
# package.prod.json is package.json with workspace: devDeps removed.
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
COPY package.prod.json package.json
RUN pnpm install --prod

# Copy source (bun runs TypeScript directly — no build step needed)
COPY src/ ./src/

RUN chown -R agent:agent /app

# Explicitly set HOME so kilo resolves XDG paths correctly.
# Some container runtimes don't set HOME when switching USER.
ENV HOME=/home/agent
USER agent

EXPOSE 8080
Expand Down
241 changes: 0 additions & 241 deletions cloudflare-gastown/container/bun.lock

This file was deleted.

4 changes: 2 additions & 2 deletions cloudflare-gastown/container/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
"lint": "eslint --config eslint.config.mjs --cache 'src/**/*.ts'"
},
"dependencies": {
"@kilocode/plugin": "1.0.23",
"@kilocode/sdk": "1.0.23",
"@kilocode/plugin": "7.0.37",
"@kilocode/sdk": "7.0.37",
"hono": "catalog:",
"zod": "catalog:"
},
Expand Down
1 change: 0 additions & 1 deletion cloudflare-gastown/container/plugin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export class GastownClient {
private agentId: string;
private rigId: string;
private townId: string;

constructor(env: GastownEnv) {
this.baseUrl = env.apiUrl.replace(/\/+$/, '');
this.token = env.sessionToken;
Expand Down
143 changes: 64 additions & 79 deletions cloudflare-gastown/container/src/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,100 +15,62 @@ function resolveEnv(request: StartAgentRequest, key: string): string | undefined
return request.envVars?.[key] ?? process.env[key];
}

/** Prepend the kilo provider prefix to an OpenRouter-style model ID. */
function kiloModel(openrouterModel: string): string {
const trimmed = openrouterModel.trim();
if (!trimmed) return 'kilo/auto';
return trimmed.startsWith('kilo/') ? trimmed : `kilo/${trimmed}`;
}

const HEADLESS_PERMISSIONS = {
edit: 'allow' as const,
bash: 'allow' as const,
webfetch: 'allow' as const,
doom_loop: 'allow' as const,
external_directory: 'allow' as const,
};

/**
* Build KILO_CONFIG_CONTENT JSON so kilo serve can authenticate with
* the Kilo LLM gateway. Mirrors the pattern in cloud-agent-next's
* session-service.ts getSaferEnvVars().
* the Kilo LLM gateway. Both `model` and `smallModel` are OpenRouter-style
* IDs (e.g. "anthropic/claude-sonnet-4.6") resolved from the town config.
*/
function buildKiloConfigContent(kilocodeToken: string, model?: string): string {
const resolvedModel = model ?? 'kilo/anthropic/claude-sonnet-4.6';
function buildKiloConfigContent(kilocodeToken: string, model: string, smallModel: string): string {
const primaryModel = kiloModel(model);
const smModel = kiloModel(smallModel);

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.
// Register models so the kilo server doesn't reject them before
// routing to the gateway.
models: {
[resolvedModel]: {},
'kilo/anthropic/claude-haiku-4.5': {},
[primaryModel]: {},
[smModel]: {},
},
},
},
// 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: 'kilo/anthropic/claude-haiku-4.5',
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.
// Override small_model (used for title generation). Without this, kilo
// serve defaults to a model that doesn't exist in the kilo provider,
// causing ProviderModelNotFoundError.
small_model: smModel,
model: primaryModel,
agent: {
code: {
model: 'kilo/anthropic/claude-sonnet-4.6',
// Auto-approve everything — agents run headless in a container,
// there's no human to answer permission prompts.
permission: {
edit: 'allow',
bash: 'allow',
webfetch: 'allow',
doom_loop: 'allow',
external_directory: 'allow',
},
},
general: {
model: 'kilo/anthropic/claude-sonnet-4.6',
// Auto-approve everything — agents run headless in a container,
// there's no human to answer permission prompts.
permission: {
edit: 'allow',
bash: 'allow',
webfetch: 'allow',
doom_loop: 'allow',
external_directory: 'allow',
},
},
plan: {
model: 'kilo/anthropic/claude-sonnet-4.6',
// Auto-approve everything — agents run headless in a container,
// there's no human to answer permission prompts.
permission: {
edit: 'allow',
bash: 'allow',
webfetch: 'allow',
doom_loop: 'allow',
external_directory: 'allow',
},
},
title: {
model: 'kilo/anthropic/claude-haiku-4.5',
},
code: { model: primaryModel, permission: HEADLESS_PERMISSIONS },
general: { model: primaryModel, permission: HEADLESS_PERMISSIONS },
plan: { model: primaryModel, permission: HEADLESS_PERMISSIONS },
title: { model: smModel },
explore: {
small_model: 'kilo/anthropic/claude-haiku-4.5',
model: 'kilo/anthropic/claude-sonnet-4.6',
// Auto-approve everything — agents run headless in a container,
// there's no human to answer permission prompts.
permission: {
edit: 'allow',
bash: 'allow',
webfetch: 'allow',
doom_loop: 'allow',
external_directory: 'allow',
},
small_model: smModel,
model: primaryModel,
permission: HEADLESS_PERMISSIONS,
},
},
// Auto-approve everything — agents run headless in a container,
// there's no human to answer permission prompts.
permission: {
edit: 'allow',
bash: 'allow',
webfetch: 'allow',
doom_loop: 'allow',
external_directory: 'allow',
},
permission: HEADLESS_PERMISSIONS,
} satisfies Config);
}

Expand Down Expand Up @@ -137,6 +99,12 @@ function buildAgentEnv(request: StartAgentRequest): Record<string, string> {
}
}

console.log(`GASTOWN_API_URL="${env.GASTOWN_API_URL}"
Comment thread
jrf0110 marked this conversation as resolved.
GASTOWN_SESSION_TOKEN=${env.GASTOWN_SESSION_TOKEN ? '(set)' : '(not set)'}
GASTOWN_AGENT_ID="${env.GASTOWN_AGENT_ID}"
GASTOWN_RIG_ID="${env.GASTOWN_RIG_ID}"
GASTOWN_TOWN_ID="${env.GASTOWN_TOWN_ID}"`);

// Fall back to X-Town-Config for KILOCODE_TOKEN if not in request or process.env
if (!env.KILOCODE_TOKEN) {
const townConfig = getCurrentTownConfig();
Expand All @@ -156,15 +124,32 @@ function buildAgentEnv(request: StartAgentRequest): Record<string, string> {
// Must also set OPENCODE_CONFIG_CONTENT — kilo serve checks both names.
const kilocodeToken = env.KILOCODE_TOKEN;
if (kilocodeToken) {
const configJson = buildKiloConfigContent(kilocodeToken, request.model);
const configJson = buildKiloConfigContent(
kilocodeToken,
request.model,
request.smallModel ?? 'anthropic/claude-haiku-4.5'
);
env.KILO_CONFIG_CONTENT = configJson;
env.OPENCODE_CONFIG_CONTENT = configJson;
const resolvedModel = request.model ?? 'kilo/anthropic/claude-sonnet-4.6';
console.log(`[buildAgentEnv] KILO_CONFIG_CONTENT set (model=${resolvedModel})`);
console.log(
`[buildAgentEnv] KILO_CONFIG_CONTENT set (model=${request.model}, smallModel=${request.smallModel ?? '(default)'})`
);
} else {
console.warn('[buildAgentEnv] No KILOCODE_TOKEN available — KILO_CONFIG_CONTENT not set');
}

// Authenticate the gh CLI via GH_TOKEN so agents can use `gh` commands.
// GIT_TOKEN is a GitHub access token set by the town config's git_auth.
// Set before the envVars loop so user-provided GH_TOKEN in town env vars
// cannot override the platform credential (intentional — prevents agents
// from being pointed at a different GitHub identity).
const ghToken = resolveEnv(request, 'GIT_TOKEN') ?? resolveEnv(request, 'GITHUB_TOKEN');
if (ghToken) {
env.GH_TOKEN = ghToken;
Comment thread
jrf0110 marked this conversation as resolved.
}

// Town-level env vars. The `!(key in env)` guard means infra-set vars
// (GASTOWN_*, KILO_*, GH_TOKEN, etc.) take precedence over user config.
if (request.envVars) {
for (const [key, value] of Object.entries(request.envVars)) {
if (!(key in env)) {
Expand Down
16 changes: 8 additions & 8 deletions cloudflare-gastown/container/src/process-manager.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/**
* Agent manager — tracks agents as SDK-managed opencode sessions.
* Agent manager — tracks agents as SDK-managed kilo sessions.
*
* Uses @kilocode/sdk's createOpencode() to start server instances in-process
* Uses @kilocode/sdk's createKilo() to start server instances in-process
* and client.event.subscribe() for typed event streams. No subprocesses,
* no SSE text parsing, no ring buffers.
*/

import { createOpencode, type OpencodeClient } from '@kilocode/sdk';
import { createKilo, type KiloClient } from '@kilocode/sdk';
import { z } from 'zod';
import type { ManagedAgent, StartAgentRequest } from './types';
import { reportAgentCompleted } from './completion-reporter';
Expand All @@ -18,7 +18,7 @@ const MANAGER_LOG = '[process-manager]';
const SessionResponse = z.object({ id: z.string().min(1) }).passthrough();

type SDKInstance = {
client: OpencodeClient;
client: KiloClient;
server: { url: string; close(): void };
sessionCount: number;
};
Expand Down Expand Up @@ -119,7 +119,7 @@ function broadcastEvent(agentId: string, event: string, data: unknown): void {
async function ensureSDKServer(
workdir: string,
env: Record<string, string>
): Promise<{ client: OpencodeClient; port: number }> {
): Promise<{ client: KiloClient; port: number }> {
const existing = sdkInstances.get(workdir);
if (existing) {
return {
Expand All @@ -131,7 +131,7 @@ async function ensureSDKServer(
const port = nextPort++;
console.log(`${MANAGER_LOG} Starting SDK server on port ${port} for ${workdir}`);

// Save env vars that we'll mutate, set them for createOpencode, then restore.
// Save env vars that we'll mutate, set them for createKilo, then restore.
// This avoids permanent global mutation when multiple agents start with
// different env — each server gets the env it was started with.
const envSnapshot: Record<string, string | undefined> = {};
Expand All @@ -144,7 +144,7 @@ async function ensureSDKServer(
const prevCwd = process.cwd();
try {
process.chdir(workdir);
const { client, server } = await createOpencode({
const { client, server } = await createKilo({
hostname: '127.0.0.1',
port,
timeout: 30_000,
Expand Down Expand Up @@ -172,7 +172,7 @@ async function ensureSDKServer(
* Subscribe to SDK events for an agent's session and forward them.
*/
async function subscribeToEvents(
client: OpencodeClient,
client: KiloClient,
agent: ManagedAgent,
request: StartAgentRequest
): Promise<void> {
Expand Down
2 changes: 2 additions & 0 deletions cloudflare-gastown/container/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const StartAgentRequest = z.object({
identity: z.string(),
prompt: z.string(),
model: z.string(),
/** Lightweight model for title generation, explore subagent, etc. */
Comment thread
jrf0110 marked this conversation as resolved.
smallModel: z.string().optional(),
systemPrompt: z.string(),
gitUrl: z.string(),
branch: z.string(),
Expand Down
16 changes: 12 additions & 4 deletions cloudflare-gastown/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,29 @@
"description": "Gastown: AI agent orchestration via Durable Objects",
"scripts": {
"preinstall": "npx only-allow pnpm",
"deploy:prod": "wrangler deploy --env=\"\"",
"deploy:dev": "wrangler deploy --env dev",
"dev": "wrangler dev --env dev",
"start": "wrangler dev --env dev",
"container:prepare": "node scripts/prepare-container.mjs",
"deploy:prod": "pnpm container:prepare && wrangler deploy --env=\"\"",
"deploy:dev": "pnpm container:prepare && wrangler deploy --env dev",
"dev": "pnpm container:prepare && wrangler dev --env dev --ip 0.0.0.0",
"start": "pnpm container:prepare && wrangler dev --env dev --ip 0.0.0.0",
"types": "wrangler types",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.workers.config.ts",
"test:integration:watch": "vitest --config vitest.workers.config.ts",
"typecheck": "tsgo --noEmit --incremental false",
"build:types": "tsgo -p tsconfig.types.json || true",
"lint": "eslint --config eslint.config.mjs --cache 'src/**/*.ts'"
},
"dependencies": {
"@cloudflare/containers": "^0.1.0",
"@hono/trpc-server": "^0.4.2",
"@kilocode/db": "workspace:*",
"drizzle-orm": "catalog:",
"jose": "catalog:",
"pg": "^8.16.3",
"@kilocode/worker-utils": "workspace:*",
"@trpc/server": "^11.0.0",
"hono": "catalog:",
"itty-time": "^1.0.6",
"jsonwebtoken": "catalog:",
Expand Down
Loading