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
30 changes: 30 additions & 0 deletions cloudflare-gastown/container/plugin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,17 @@ export class GastownClient {
});
}

async nudge(input: {
target_agent_id: string;
message: string;
mode: 'wait-idle' | 'immediate' | 'queue';
}): Promise<{ nudge_id: string }> {
return this.request<{ nudge_id: string }>(this.rigPath('/nudge'), {
method: 'POST',
body: JSON.stringify(input),
});
}

async createEscalation(input: {
title: string;
body?: string;
Expand Down Expand Up @@ -351,6 +362,25 @@ export class MayorGastownClient {
});
}

async nudge(input: {
rig_id: string;
target_agent_id: string;
message: string;
mode: 'wait-idle' | 'immediate' | 'queue';
}): Promise<{ nudge_id: string }> {
return this.request<{ nudge_id: string }>(
`${this.baseUrl}/api/towns/${this.townId}/rigs/${input.rig_id}/nudge`,
{
method: 'POST',
body: JSON.stringify({
target_agent_id: input.target_agent_id,
message: input.message,
mode: input.mode,
}),
}
);
}

async listConvoys(): Promise<Convoy[]> {
return this.request<Convoy[]>(this.mayorPath('/convoys'));
}
Expand Down
31 changes: 31 additions & 0 deletions cloudflare-gastown/container/plugin/mayor-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ function makeFakeMayorClient(overrides: Partial<MayorGastownClient> = {}): Mayor
listBeads: vi.fn<() => Promise<Bead[]>>().mockResolvedValue([]),
listAgents: vi.fn<() => Promise<Agent[]>>().mockResolvedValue([]),
sendMail: vi.fn().mockResolvedValue(undefined),
nudge: vi.fn<() => Promise<{ nudge_id: string }>>().mockResolvedValue({ nudge_id: 'nudge-1' }),
slingBatch: vi.fn<() => Promise<SlingBatchResult>>().mockResolvedValue({
convoy: FAKE_CONVOY,
beads: [
Expand Down Expand Up @@ -409,4 +410,34 @@ describe('mayor tools', () => {
expect(client.startConvoy).toHaveBeenCalledWith('convoy-staged-1');
});
});

describe('gt_nudge', () => {
it('sends a nudge and returns the nudge_id', async () => {
const result = await tools.gt_nudge.execute(
{ rig_id: 'rig-1', target_agent_id: 'agent-1', message: 'Wake up!' },
CTX
);
expect(result).toContain('nudge-1');
expect(result).toContain('wait-idle');
expect(client.nudge).toHaveBeenCalledWith({
rig_id: 'rig-1',
target_agent_id: 'agent-1',
message: 'Wake up!',
mode: 'wait-idle',
});
});

it('passes explicit mode through to the client', async () => {
await tools.gt_nudge.execute(
{ rig_id: 'rig-1', target_agent_id: 'agent-2', message: 'Urgent!', mode: 'immediate' },
CTX
);
expect(client.nudge).toHaveBeenCalledWith({
rig_id: 'rig-1',
target_agent_id: 'agent-2',
message: 'Urgent!',
mode: 'immediate',
});
});
});
});
29 changes: 29 additions & 0 deletions cloudflare-gastown/container/plugin/mayor-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,5 +446,34 @@ export function createMayorTools(client: MayorGastownClient) {
return `UI action "${action.type}" broadcast to dashboard.`;
},
}),

gt_nudge: tool({
description:
'Send a real-time nudge to a polecat agent in any rig. Unlike gt_mail_send (which queues ' +
"a formal persistent message), gt_nudge delivers immediately at the agent's next idle moment. " +
'Use this for time-sensitive coordination: wake up an agent, request a status check, ' +
'or notify of a blocking issue.',
args: {
rig_id: tool.schema.string().describe('The UUID of the rig the target agent belongs to'),
target_agent_id: tool.schema.string().describe('UUID of the agent to nudge'),
message: tool.schema.string().describe('The message to deliver'),
mode: tool.schema
.enum(['wait-idle', 'immediate', 'queue'])
.describe(
'Delivery mode: wait-idle (default) delivers at next idle moment; ' +
'immediate injects mid-task; queue delivers with TTL'
)
.optional(),
},
async execute(args) {
const result = await client.nudge({
rig_id: args.rig_id,
target_agent_id: args.target_agent_id,
message: args.message,
mode: args.mode ?? 'wait-idle',
});
return `Nudge queued: ${result.nudge_id} (mode: ${args.mode ?? 'wait-idle'})`;
},
}),
};
}
29 changes: 28 additions & 1 deletion cloudflare-gastown/container/plugin/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ export function createTools(client: GastownClient) {
'Emit a plain-language status update visible on the dashboard. ' +
'Call this when starting a new phase of work (e.g. "Installing dependencies", ' +
'"Writing tests", "Fixing lint errors"). Write it as a brief sentence for a teammate, ' +
'not a log line. Do NOT call this on every tool use — only at meaningful phase transitions.',
'not a log line. Do NOT call this on every tool use â only at meaningful phase transitions.',
args: {
message: tool.schema
.string()
Expand All @@ -228,5 +228,32 @@ export function createTools(client: GastownClient) {
return 'Status updated.';
},
}),

gt_nudge: tool({
description:
'Send a real-time nudge to another agent. Unlike gt_mail_send (which queues a formal ' +
"persistent message), gt_nudge delivers immediately at the agent's next idle moment. " +
'Use this for time-sensitive coordination: wake up an agent, request a status check, ' +
'or notify of a blocking issue.',
args: {
target_agent_id: tool.schema.string().describe('UUID of the agent to nudge'),
message: tool.schema.string().describe('The message to deliver'),
mode: tool.schema
.enum(['wait-idle', 'immediate', 'queue'])
.describe(
'Delivery mode: wait-idle (default) delivers at next idle moment; ' +
'immediate injects mid-task; queue delivers with TTL'
)
.optional(),
},
async execute(args) {
const result = await client.nudge({
target_agent_id: args.target_agent_id,
message: args.message,
mode: args.mode ?? 'wait-idle',
});
return `Nudge queued: ${result.nudge_id} (mode: ${args.mode ?? 'wait-idle'})`;
},
}),
};
}
79 changes: 79 additions & 0 deletions cloudflare-gastown/container/src/control-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,85 @@ app.post('/git/merge', async c => {
return c.json(result, 202);
});

// GET /agents/:agentId/pending-nudges
// Proxies to the gastown worker to fetch undelivered nudges for an agent.
// Called by the process-manager when the agent goes idle.
app.get('/agents/:agentId/pending-nudges', async c => {
const { agentId } = c.req.param();
const apiUrl = process.env.GASTOWN_API_URL;
const token = process.env.GASTOWN_CONTAINER_TOKEN ?? process.env.GASTOWN_SESSION_TOKEN;
const townId = process.env.GASTOWN_TOWN_ID;
const rigId = process.env.GASTOWN_RIG_ID;

if (!apiUrl || !token || !townId || !rigId) {
return c.json({ error: 'Missing gastown configuration' }, 503);
}

try {
const resp = await fetch(
`${apiUrl}/api/towns/${townId}/rigs/${rigId}/agents/${agentId}/pending-nudges`,
{
headers: {
Authorization: `Bearer ${token}`,
'X-Gastown-Agent-Id': agentId,
'X-Gastown-Rig-Id': rigId,
},
}
);
const body: unknown = await resp.json();
return c.json(body, resp.status as 200);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return c.json({ error: message }, 500);
}
});

// POST /agents/:agentId/nudge-delivered
// Marks a nudge as delivered via the gastown worker.
// Body: { nudge_id: string }
app.post('/agents/:agentId/nudge-delivered', async c => {
const { agentId } = c.req.param();
const apiUrl = process.env.GASTOWN_API_URL;
const token = process.env.GASTOWN_CONTAINER_TOKEN ?? process.env.GASTOWN_SESSION_TOKEN;
const townId = process.env.GASTOWN_TOWN_ID;
const rigId = process.env.GASTOWN_RIG_ID;

if (!apiUrl || !token || !townId || !rigId) {
return c.json({ error: 'Missing gastown configuration' }, 503);
}

const body: unknown = await c.req.json().catch(() => null);
if (
!body ||
typeof body !== 'object' ||
!('nudge_id' in body) ||
typeof body.nudge_id !== 'string'
) {
return c.json({ error: 'Missing or invalid nudge_id field' }, 400);
}

try {
const resp = await fetch(
`${apiUrl}/api/towns/${townId}/rigs/${rigId}/agents/${agentId}/nudge-delivered`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'X-Gastown-Agent-Id': agentId,
'X-Gastown-Rig-Id': rigId,
},
body: JSON.stringify({ nudge_id: body.nudge_id }),
}
);
const respBody: unknown = await resp.json();
return c.json(respBody, resp.status as 200);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return c.json({ error: message }, 500);
}
});

// ── PTY proxy routes ──────────────────────────────────────────────────
// Proxy PTY operations to the agent's internal SDK server.
// The SDK server (kilo serve) exposes /pty/* routes on 127.0.0.1:<port>.
Expand Down
Loading
Loading