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
142 changes: 141 additions & 1 deletion cloudflare-gastown/container/plugin/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
import type { ApiResponse, Bead, BeadPriority, GastownEnv, Mail, PrimeContext } from './types';
import type {
Agent,
ApiResponse,
Bead,
BeadPriority,
BeadStatus,
BeadType,
GastownEnv,
Mail,
MayorGastownEnv,
PrimeContext,
Rig,
SlingResult,
} from './types';

function isApiResponse(value: unknown): value is ApiResponse<unknown> {
return (
Expand Down Expand Up @@ -130,6 +143,114 @@ export class GastownClient {
}
}

/**
* Mayor-scoped client for town-level cross-rig operations.
* Uses `/api/mayor/:townId/tools/*` routes authenticated via townId-scoped JWT.
*/
export class MayorGastownClient {
private baseUrl: string;
private token: string;
private agentId: string;
private townId: string;

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

private mayorPath(path: string): string {
return `${this.baseUrl}/api/mayor/${this.townId}/tools${path}`;
}

private async request<T>(url: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers);
headers.set('Content-Type', 'application/json');
headers.set('Authorization', `Bearer ${this.token}`);

let response: Response;
try {
response = await fetch(url, { ...init, headers });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new GastownApiError(`Network error: ${message}`, 0);
}

if (response.status === 204) {
return undefined as T;
}

let body: unknown;
try {
body = await response.json();
} catch {
throw new GastownApiError(`Invalid JSON response (HTTP ${response.status})`, response.status);
}

if (!isApiResponse(body)) {
throw new GastownApiError(
`Unexpected response shape (HTTP ${response.status})`,
response.status
);
}

if (!body.success) {
throw new GastownApiError((body as { error: string }).error, response.status);
}

return (body as { data: T }).data;
}

// -- Mayor tool endpoints --

async sling(input: {
rig_id: string;
title: string;
body?: string;
metadata?: Record<string, unknown>;
}): Promise<SlingResult> {
return this.request<SlingResult>(this.mayorPath('/sling'), {
method: 'POST',
body: JSON.stringify(input),
});
}

async listRigs(): Promise<Rig[]> {
return this.request<Rig[]>(this.mayorPath('/rigs'));
}

async listBeads(
rigId: string,
filter?: { status?: BeadStatus; type?: BeadType }
): Promise<Bead[]> {
const params = new URLSearchParams();
if (filter?.status) params.set('status', filter.status);
if (filter?.type) params.set('type', filter.type);
const qs = params.toString();
return this.request<Bead[]>(this.mayorPath(`/rigs/${rigId}/beads${qs ? `?${qs}` : ''}`));
}

async listAgents(rigId: string): Promise<Agent[]> {
return this.request<Agent[]>(this.mayorPath(`/rigs/${rigId}/agents`));
}

async sendMail(input: {
rig_id: string;
to_agent_id: string;
subject: string;
body: string;
}): Promise<void> {
await this.request<void>(this.mayorPath('/mail'), {
method: 'POST',
body: JSON.stringify({
...input,
from_agent_id: this.agentId,
}),
});
}
}

export class GastownApiError extends Error {
readonly status: number;

Expand Down Expand Up @@ -158,3 +279,22 @@ export function createClientFromEnv(): GastownClient {

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

export function createMayorClientFromEnv(): MayorGastownClient {
const apiUrl = process.env.GASTOWN_API_URL;
const sessionToken = process.env.GASTOWN_SESSION_TOKEN;
const agentId = process.env.GASTOWN_AGENT_ID;
const townId = process.env.GASTOWN_TOWN_ID;

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

return new MayorGastownClient({ apiUrl, sessionToken, agentId, townId });
}
35 changes: 24 additions & 11 deletions cloudflare-gastown/container/plugin/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Plugin } from '@opencode-ai/plugin';
import { createClientFromEnv, GastownApiError } from './client';
import { createClientFromEnv, createMayorClientFromEnv, GastownApiError } from './client';
import { createTools } from './tools';
import { createMayorTools } from './mayor-tools';

const SERVICE = 'gastown-plugin';

Expand All @@ -17,8 +18,16 @@ function formatPrimeContextForInjection(primeResult: string): string {
}

export const GastownPlugin: Plugin = async ({ client }) => {
const gastownClient = createClientFromEnv();
const tools = createTools(gastownClient);
const isMayor = process.env.GASTOWN_AGENT_ROLE === 'mayor';

// Mayor gets town-scoped tools; rig agents get rig-scoped tools.
// The mayor doesn't have a rigId — it operates across rigs.
const gastownClient = isMayor ? null : createClientFromEnv();
const mayorClient = isMayor ? createMayorClientFromEnv() : null;

const rigTools = gastownClient ? createTools(gastownClient) : {};
const mayorTools = mayorClient ? createMayorTools(mayorClient) : {};
const tools = { ...rigTools, ...mayorTools };

// Best-effort logging — never let telemetry failures break tool execution
async function log(level: 'info' | 'error', message: string) {
Expand All @@ -29,8 +38,9 @@ export const GastownPlugin: Plugin = async ({ client }) => {
}
}

// Prime on session start and inject into context
async function primeAndLog(): Promise<string> {
// Prime on session start and inject context (rig agents only — mayor has no prime)
async function primeAndLog(): Promise<string | null> {
if (!gastownClient) return null;
try {
const ctx = await gastownClient.prime();
await log('info', 'primed successfully');
Expand All @@ -46,7 +56,7 @@ export const GastownPlugin: Plugin = async ({ client }) => {
tool: tools,

event: async ({ event }) => {
if (event.type === 'session.deleted') {
if (event.type === 'session.deleted' && gastownClient) {
// Notify Rig DO that session ended — best-effort, don't throw
try {
await gastownClient.writeCheckpoint({
Expand All @@ -61,20 +71,23 @@ export const GastownPlugin: Plugin = async ({ client }) => {
}
},

// Inject prime context into the system prompt on the first message
// Inject prime context into the system prompt on the first message (rig agents only)
'experimental.chat.system.transform': async (_input, output) => {
// Only inject once — check if already present
const alreadyInjected = output.system.some(s => s.includes('GASTOWN CONTEXT'));
if (!alreadyInjected) {
const primeResult = await primeAndLog();
output.system.push(formatPrimeContextForInjection(primeResult));
if (primeResult) {
output.system.push(formatPrimeContextForInjection(primeResult));
}
}
},

// Re-inject prime context after compaction so the agent doesn't lose orientation
// Re-inject prime context after compaction (rig agents only)
'experimental.session.compacting': async (_input, output) => {
const primeResult = await primeAndLog();
output.context.push(formatPrimeContextForInjection(primeResult));
if (primeResult) {
output.context.push(formatPrimeContextForInjection(primeResult));
}
},
};
};
145 changes: 145 additions & 0 deletions cloudflare-gastown/container/plugin/mayor-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { tool } from '@opencode-ai/plugin';
import type { MayorGastownClient } from './client';

function parseJsonObject(value: string, label: string): Record<string, unknown> {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error(`Invalid JSON in "${label}"`);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(
`"${label}" must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`
);
}
return parsed as Record<string, unknown>;
}

/**
* Mayor-specific tools for cross-rig delegation.
* These are only registered when `GASTOWN_AGENT_ROLE=mayor`.
*/
export function createMayorTools(client: MayorGastownClient) {
return {
gt_sling: tool({
description:
'Delegate a task to a polecat agent in a specific rig. ' +
'Creates a bead (work item), assigns a polecat, and arms the dispatch alarm. ' +
'The polecat will be started automatically and begin working on the task. ' +
'You must specify which rig the work belongs to — use gt_list_rigs first if unsure.',
args: {
rig_id: tool.schema.string().describe('The UUID of the rig to assign work to'),
title: tool.schema.string().describe('Short title describing the task'),
body: tool.schema
.string()
.describe(
'Detailed description of the work to be done. Include requirements, context, acceptance criteria.'
)
.optional(),
metadata: tool.schema
.string()
.describe('JSON-encoded metadata object for additional context')
.optional(),
},
async execute(args) {
const metadata = args.metadata ? parseJsonObject(args.metadata, 'metadata') : undefined;
const result = await client.sling({
rig_id: args.rig_id,
title: args.title,
body: args.body,
metadata,
});
return [
`Task slung successfully.`,
`Bead: ${result.bead.id} — "${result.bead.title}"`,
`Assigned to: ${result.agent.name} (${result.agent.role}, id: ${result.agent.id})`,
`Status: ${result.bead.status}`,
`The polecat will be dispatched automatically by the alarm scheduler.`,
].join('\n');
},
}),

gt_list_rigs: tool({
description:
'List all rigs (repositories) in your town. ' +
'Returns the rig ID, name, git URL, and default branch for each rig. ' +
'Use this to discover available rigs before delegating work with gt_sling.',
args: {},
async execute() {
const rigs = await client.listRigs();
if (rigs.length === 0) {
return 'No rigs configured in this town. A rig must be created before work can be delegated.';
}
return JSON.stringify(rigs, null, 2);
},
}),

gt_list_beads: tool({
description:
'List beads (work items) in a specific rig. ' +
'Optionally filter by status (open, in_progress, closed, failed) or type (issue, message, escalation, merge_request). ' +
'Use this to check what work exists in a rig, what is in progress, and what has been completed.',
args: {
rig_id: tool.schema.string().describe('The UUID of the rig to list beads from'),
status: tool.schema
.enum(['open', 'in_progress', 'closed', 'failed'])
.describe('Filter by bead status')
.optional(),
type: tool.schema
.enum(['issue', 'message', 'escalation', 'merge_request'])
.describe('Filter by bead type')
.optional(),
},
async execute(args) {
const beads = await client.listBeads(args.rig_id, {
status: args.status,
type: args.type,
});
if (beads.length === 0) {
return 'No beads found matching the filter.';
}
return JSON.stringify(beads, null, 2);
},
}),

gt_list_agents: tool({
description:
'List all agents in a specific rig. ' +
'Returns agent ID, role, name, status, and current hook (assigned bead). ' +
'Use this to see which agents are active, idle, or working on what.',
args: {
rig_id: tool.schema.string().describe('The UUID of the rig to list agents from'),
},
async execute(args) {
const agents = await client.listAgents(args.rig_id);
if (agents.length === 0) {
return 'No agents registered in this rig.';
}
return JSON.stringify(agents, null, 2);
},
}),

gt_mail_send: tool({
description:
'Send a mail message to an agent in any rig. ' +
'Use this for cross-rig coordination, instructions, or status requests. ' +
'The recipient must be identified by their agent UUID and rig UUID.',
args: {
rig_id: tool.schema.string().describe('The UUID of the rig the recipient agent belongs to'),
to_agent_id: tool.schema.string().describe('The UUID of the recipient agent'),
subject: tool.schema.string().describe('Subject line for the mail'),
body: tool.schema.string().describe('Body content of the mail'),
},
async execute(args) {
await client.sendMail({
rig_id: args.rig_id,
to_agent_id: args.to_agent_id,
subject: args.subject,
body: args.body,
});
return `Mail sent to agent ${args.to_agent_id} in rig ${args.rig_id}.`;
},
}),
};
}
Loading