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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
Container,
User,
Key,
MessageSquareText,
X,
} from 'lucide-react';
import {
Expand All @@ -40,6 +41,7 @@ import {
AccordionContent,
} from '@/components/ui/accordion';
import { Slider } from '@/components/ui/slider';
import { Textarea } from '@/components/ui/textarea';
import { motion } from 'motion/react';
import { AdminViewingBanner } from '@/components/gastown/AdminViewingBanner';
import { useRouter } from 'next/navigation';
Expand Down Expand Up @@ -71,6 +73,7 @@ const SECTIONS = [
{ id: 'merge-strategy', label: 'Merge Strategy', icon: GitPullRequest },
{ id: 'refinery', label: 'Refinery', icon: Shield },
{ id: 'container', label: 'Container', icon: Container },
{ id: 'custom-instructions', label: 'Custom Instructions', icon: MessageSquareText },
{ id: 'danger-zone', label: 'Danger Zone', icon: Trash2 },
] as const;

Expand Down Expand Up @@ -280,6 +283,9 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI
const [gitAuthorName, setGitAuthorName] = useState('');
const [gitAuthorEmail, setGitAuthorEmail] = useState('');
const [disableAiCoauthor, setDisableAiCoauthor] = useState(false);
const [polecatInstructions, setPolecatInstructions] = useState('');
const [refineryInstructions, setRefineryInstructions] = useState('');
const [mayorInstructions, setMayorInstructions] = useState('');
const [initialized, setInitialized] = useState(false);
const [showTokens, setShowTokens] = useState(false);

Expand Down Expand Up @@ -316,6 +322,9 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI
setGitAuthorName(cfg.git_author_name ?? '');
setGitAuthorEmail(cfg.git_author_email ?? '');
setDisableAiCoauthor(cfg.disable_ai_coauthor ?? false);
setPolecatInstructions(cfg.custom_instructions?.polecat ?? '');
setRefineryInstructions(cfg.custom_instructions?.refinery ?? '');
setMayorInstructions(cfg.custom_instructions?.mayor ?? '');
setInitialized(true);
}

Expand Down Expand Up @@ -366,6 +375,11 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI
auto_merge_delay_minutes: autoMergeDelayMinutes,
},
convoy_merge_mode: convoyMergeMode,
custom_instructions: {
polecat: polecatInstructions || undefined,
refinery: refineryInstructions || undefined,
mayor: mayorInstructions || undefined,
},
},
});
}
Expand Down Expand Up @@ -1057,13 +1071,47 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI
</div>
</SettingsSection>

{/* ── Custom Instructions ────────────────────────────────── */}
<SettingsSection
id="custom-instructions"
title="Custom Instructions"
description="Customize the system prompt for each agent role. These instructions are appended to the default prompt and apply to all agents of that role."
icon={MessageSquareText}
index={10}
>
<div className="space-y-5">
{(
[
['Polecat Instructions', polecatInstructions, setPolecatInstructions],
['Refinery Instructions', refineryInstructions, setRefineryInstructions],
['Mayor Instructions', mayorInstructions, setMayorInstructions],
] as const
).map(([roleLabel, value, setValue]) => (
<FieldGroup key={roleLabel} label={roleLabel}>
<div className="relative">
<Textarea
value={value}
onChange={e => setValue(e.target.value.slice(0, 2000))}
placeholder={`Custom instructions for ${roleLabel.replace(' Instructions', '').toLowerCase()} agents…`}
rows={4}
className="border-white/[0.08] bg-white/[0.03] text-sm text-white/85 placeholder:text-white/20"
/>
<span className="absolute right-2 bottom-2 text-[10px] text-white/20">
{value.length} / 2000
</span>
</div>
</FieldGroup>
))}
</div>
</SettingsSection>

{/* ── Danger Zone ──────────────────────────────────────── */}
<SettingsSection
id="danger-zone"
title="Danger Zone"
description="Irreversible actions for this town."
icon={Trash2}
index={9}
index={11}
>
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border border-red-500/20 bg-red-500/5 px-4 py-3">
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/gastown/AgentDetailDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function AgentDetailDrawer({
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 z-50 bg-black/60" />
<Drawer.Content
className="fixed top-0 right-0 bottom-0 z-50 flex w-[480px] max-w-[94vw] flex-col outline-none"
className="fixed top-0 right-0 bottom-0 z-50 flex w-[600px] max-w-[94vw] flex-col outline-none"
style={{ '--initial-transform': 'calc(100% + 8px)' } as React.CSSProperties}
>
<div className="flex h-full flex-col overflow-hidden rounded-l-2xl border-l border-white/[0.08] bg-[oklch(0.12_0_0)]">
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/gastown/BeadDetailDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function BeadDetailDrawer({
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 z-50 bg-black/60" />
<Drawer.Content
className="fixed top-0 right-0 bottom-0 z-50 flex w-[520px] max-w-[94vw] flex-col outline-none"
className="fixed top-0 right-0 bottom-0 z-50 flex w-[640px] max-w-[94vw] flex-col outline-none"
style={{ '--initial-transform': 'calc(100% + 8px)' } as React.CSSProperties}
>
<div className="flex h-full flex-col overflow-hidden rounded-l-2xl border-l border-white/[0.08] bg-[oklch(0.12_0_0)]">
Expand All @@ -76,7 +76,7 @@ export function BeadDetailDrawer({
<div className="min-w-0 flex-1">
<Drawer.Title className="flex items-center gap-2 text-base font-semibold text-white/90">
<Hexagon className="size-4 shrink-0 text-[color:oklch(95%_0.15_108_/_0.7)]" />
<span className="truncate">{bead?.title ?? 'Bead'}</span>
<span>{bead?.title ?? 'Bead'}</span>
</Drawer.Title>
<Drawer.Description className="mt-1.5 text-xs text-white/35">
Full inspection view — metadata, body, and event timeline.
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/gastown/DrawerStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export function DrawerStackProvider({

// ── Renderer ─────────────────────────────────────────────────────────────

const DRAWER_WIDTH = 500;
const DRAWER_WIDTH = 620;
/** How many px each background layer shifts left per depth level */
const DEPTH_OFFSET = 40;
/** Extra shift on hover */
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/gastown/EventDetailDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export function EventDetailDrawer({ open, onOpenChange, event }: EventDetailDraw
<Drawer.Portal>
<Drawer.Overlay className="fixed inset-0 z-50 bg-black/50" />
<Drawer.Content
className="fixed top-0 right-0 bottom-0 z-50 flex w-[440px] max-w-[92vw] flex-col outline-none"
className="fixed top-0 right-0 bottom-0 z-50 flex w-[560px] max-w-[92vw] flex-col outline-none"
style={{ '--initial-transform': 'calc(100% + 8px)' } as React.CSSProperties}
>
<div className="flex h-full flex-col overflow-hidden rounded-l-2xl border-l border-white/[0.08] bg-[oklch(0.12_0_0)]">
Expand Down
6 changes: 2 additions & 4 deletions apps/web/src/components/gastown/GastownBeadDetailSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,14 @@ export function GastownBeadDetailSheet({
<SheetContent
side="right"
className={cn(
'w-[540px] max-w-[92vw] border-white/10 bg-[color:oklch(0.155_0_0)]',
'w-[660px] max-w-[92vw] border-white/10 bg-[color:oklch(0.155_0_0)]',
'shadow-[0_30px_120px_-70px_rgba(0,0,0,0.95)]'
)}
>
<SheetHeader className="gap-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<SheetTitle className="truncate text-base text-white/90">
{bead?.title ?? 'Bead'}
</SheetTitle>
<SheetTitle className="text-base text-white/90">{bead?.title ?? 'Bead'}</SheetTitle>
<SheetDescription className="mt-1 text-xs text-white/45">
Click-through audit trail: events, status changes, hooks, and mail.
</SheetDescription>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ export function BeadPanel({
className="h-7 border-white/10 bg-white/5 text-sm font-semibold text-white/90"
/>
) : (
<span className="truncate text-base font-semibold text-white/90">{bead.title}</span>
<span className="text-base font-semibold text-white/90">{bead.title}</span>
)}
<button
onClick={editing ? cancelEdit : enterEditMode}
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/lib/gastown/types/router.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,13 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter<
git_author_name?: string | undefined;
git_author_email?: string | undefined;
disable_ai_coauthor: boolean;
custom_instructions?:
| {
polecat?: string | undefined;
refinery?: string | undefined;
mayor?: string | undefined;
}
| undefined;
};
meta: object;
}>;
Expand Down Expand Up @@ -561,6 +568,13 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter<
git_author_name?: string | undefined;
git_author_email?: string | undefined;
disable_ai_coauthor?: boolean | undefined;
custom_instructions?:
| {
polecat?: string | undefined;
refinery?: string | undefined;
mayor?: string | undefined;
}
| undefined;
};
};
output: {
Expand Down Expand Up @@ -612,6 +626,13 @@ export declare const gastownRouter: import('@trpc/server').TRPCBuiltRouter<
git_author_name?: string | undefined;
git_author_email?: string | undefined;
disable_ai_coauthor: boolean;
custom_instructions?:
| {
polecat?: string | undefined;
refinery?: string | undefined;
mayor?: string | undefined;
}
| undefined;
};
meta: object;
}>;
Expand Down Expand Up @@ -1815,6 +1836,13 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute
git_author_name?: string | undefined;
git_author_email?: string | undefined;
disable_ai_coauthor: boolean;
custom_instructions?:
| {
polecat?: string | undefined;
refinery?: string | undefined;
mayor?: string | undefined;
}
| undefined;
};
meta: object;
}>;
Expand Down Expand Up @@ -1872,6 +1900,13 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute
git_author_name?: string | undefined;
git_author_email?: string | undefined;
disable_ai_coauthor?: boolean | undefined;
custom_instructions?:
| {
polecat?: string | undefined;
refinery?: string | undefined;
mayor?: string | undefined;
}
| undefined;
};
};
output: {
Expand Down Expand Up @@ -1923,6 +1958,13 @@ export declare const wrappedGastownRouter: import('@trpc/server').TRPCBuiltRoute
git_author_name?: string | undefined;
git_author_email?: string | undefined;
disable_ai_coauthor: boolean;
custom_instructions?:
| {
polecat?: string | undefined;
refinery?: string | undefined;
mayor?: string | undefined;
}
| undefined;
};
meta: object;
}>;
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/routers/admin/gastown-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ const TownConfigRecord = z.object({
alarm_interval_idle: z.number().optional(),
container: z.object({ sleep_after_minutes: z.number().optional() }).optional(),
staged_convoys_default: z.boolean().optional(),
custom_instructions: z
Comment thread
jrf0110 marked this conversation as resolved.
.object({
polecat: z.string().optional(),
refinery: z.string().optional(),
mayor: z.string().optional(),
})
.optional(),
});

const ConvoyDetailRecord = z.object({
Expand Down
2 changes: 1 addition & 1 deletion services/gastown/container/src/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ async function createMayorWorkspace(rigId: string): Promise<string> {
* user customization), the TownDO sends the updated prompt and we
* rewrite this file.
*/
async function writeMayorSystemPromptToAgentsMd(
export async function writeMayorSystemPromptToAgentsMd(
workspaceDir: string,
systemPrompt: string
): Promise<void> {
Expand Down
25 changes: 24 additions & 1 deletion services/gastown/container/src/control-server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Hono } from 'hono';
import { z } from 'zod';
import { runAgent, resolveGitCredentials } from './agent-runner';
import { runAgent, resolveGitCredentials, writeMayorSystemPromptToAgentsMd } from './agent-runner';
import {
stopAgent,
sendMessage,
Expand Down Expand Up @@ -318,6 +318,29 @@ app.patch('/agents/:agentId/model', async c => {
return c.json({ updated: true });
});

// PUT /agents/:agentId/system-prompt
// Rewrite the mayor's AGENTS.md with an updated system prompt.
// Used when custom instructions change so the running mayor picks them up
// on its next session restart without a full container restart.
app.put('/agents/:agentId/system-prompt', async c => {
const { agentId } = c.req.param();
const agent = getAgentStatus(agentId);
if (!agent) {
return c.json({ error: `Agent ${agentId} not found` }, 404);
}
const body: unknown = await c.req.json().catch(() => null);
if (
!body ||
typeof body !== 'object' ||
!('systemPrompt' in body) ||
typeof body.systemPrompt !== 'string'
) {
return c.json({ error: 'Missing or invalid systemPrompt field' }, 400);
}
await writeMayorSystemPromptToAgentsMd(agent.workdir, body.systemPrompt);
return c.json({ updated: true });
});

// GET /agents/:agentId/status
app.get('/agents/:agentId/status', c => {
const { agentId } = c.req.param();
Expand Down
57 changes: 57 additions & 0 deletions services/gastown/docs/local-debug-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,60 @@ Triage/escalation beads pile up with `rig_id=NULL`. These are by design:
- GUPP force-stop beads are created by the patrol system for stuck agents

During testing, container restarts generate many of these. Bulk-close via admin panel if needed.

## 7. Auto-Merge with Workers AI Thread Classification

The auto-merge flow uses Workers AI (Gemma 4 26B) to classify unresolved PR review threads as blocking vs non-blocking. This prevents informational bot comments (status reports, code review summaries) from blocking auto-merge.

### How It Works

1. `poll_pr` runs every ~60s for MR beads with a `pr_url`
2. `checkPRFeedback` fetches review threads via GitHub GraphQL (including comment bodies)
3. If unresolved threads exist, `areThreadsBlocking()` sends them to Workers AI
4. The model classifies threads as BLOCKING (requires code changes, bugs, security) or NON-BLOCKING (informational, nits, bot status reports)
5. Only truly blocking threads prevent auto-merge

### Config Required

Set these on the town config (via `PATCH /debug/towns/:townId/config`):

```json
{
"refinery": {
"auto_merge": true,
"auto_merge_delay_minutes": 0,
"auto_resolve_pr_feedback": true
}
}
```

### Testing Locally

The `areThreadsBlocking` AI call only triggers when:

- An MR bead has a `pr_url` (external GitHub PR exists)
- The PR has unresolved review threads on GitHub
- `auto_merge` is enabled in the town config

In local dev, PRs created by the refinery in review-and-merge mode may not create external GitHub PRs, so the AI classification branch won't fire. To test the AI path end-to-end:

1. Manually create a PR with unresolved review comments on the target repo
2. Create an MR bead that references that PR
3. Watch the wrangler logs for `areThreadsBlocking` output

### Monitoring in Production

Query Analytics Engine for the `areThreadsBlocking` log output:

```bash
# Check wrangler tail for AI classification logs
npx wrangler tail gastown --format json --search "areThreadsBlocking"
```

The `areThreadsBlocking` method logs its decision:

```
[town] areThreadsBlocking: blocking=false reason=<explanation> threads=2
```

If the AI call fails, it conservatively defaults to `blocking=true` and logs a warning.
Loading
Loading