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
9 changes: 9 additions & 0 deletions apps/server/src/agents/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
probeGitContext,
} from "../shared/git/git-context.js";
import { getActivePersonality } from "../db/personalities.js";
import { isTrimmedLaunchGuidanceEnabled } from "../launch-guidance-settings.js";
import { findCodexSessionId } from "./codex-sessions.js";
import { harvestTokenUsage } from "./token-harvester.js";
import { errorMessage } from "../shared/lib/error-message.js";
Expand Down Expand Up @@ -696,6 +697,11 @@ export class AgentManager {
opts.persona || opts.jobRunId || role === "assisted_update"
? null
: await getActivePersonality(this.pool);
// Job runs get their own ruleset, which the trim never touches — so
// don't make an unchanged launch path depend on this settings read.
const trimmedGuidance = opts.jobRunId
? false
: await isTrimmedLaunchGuidanceEnabled(this.pool);

const agentCommand = buildAgentCommand(
this.config,
Expand All @@ -713,6 +719,7 @@ export class AgentManager {
jobRunId: opts.jobRunId,
}),
autoReview: !opts.persona && !opts.jobRunId && opts.autoReview,
trimmedGuidance,
initialPrompt: startupPrompt,
personalityPrompt: personality?.prompt ?? null,
model,
Expand Down Expand Up @@ -906,6 +913,7 @@ export class AgentManager {
agent.persona || agent.role === "assisted_update"
? null
: await getActivePersonality(this.pool);
const trimmedGuidance = await isTrimmedLaunchGuidanceEnabled(this.pool);

const agentCommand = buildAgentCommand(
this.config,
Expand All @@ -922,6 +930,7 @@ export class AgentManager {
persona: agent.persona,
}),
autoReview: !agent.persona && (agent.autoReview ?? false),
trimmedGuidance,
personalityPrompt: personality?.prompt ?? null,
model: agent.model ?? undefined,
}
Expand Down
106 changes: 92 additions & 14 deletions apps/server/src/agents/tmux/command-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ const CLI_BY_AGENT_TYPE: Record<
const DISPATCH_API_URL_ENV = "DISPATCH_API_URL";
const DISPATCH_RELEASE_UPDATE_TOKEN_ENV = "DISPATCH_RELEASE_UPDATE_TOKEN";

/**
* Agent types that can install the Dispatch plugin, whose skills carry the
* depth the trimmed rules drop. Opencode and Cursor have no plugin at all, so
* they keep the full guidance even when the trim setting is on — otherwise
* they'd lose that guidance with nothing replacing it.
*/
const PLUGIN_CAPABLE_AGENT_TYPES: ReadonlySet<AgentType> = new Set([
"claude",
"codex",
]);

/**
* Pull a `--append-system-prompt <value>` pair out of an arg list (codex /
* opencode put system prompts in their own flag). Claude doesn't need this
Expand Down Expand Up @@ -146,6 +157,40 @@ export function buildStartupPrompt(

/**
* Build the numbered launch guidance text shared by all CLI agent types.
*
* `trimmedGuidance` swaps the verbose rules for short generic ones. Two
* different things carry the detail it drops, and the distinction matters:
*
* - **The MCP tool schemas.** `dispatch_pin`'s own description already lists
* every pin type, explains shortcut/confirm/disabled, and says to pair a
* blocking shortcut with `waiting_user`; `dispatch_event`'s enumerates the
* status types. Restating them here duplicated a description the agent
* already has, in every session, whether or not the flow ever comes up. The
* trimmed rules say *that* these tools matter and leave the *how* to the
* schema. This half does not depend on the plugin at all.
* - **Plugin skills**, for the Playwright methodology (→ `ui-validation` +
* `sharing`) and the `create_pr` routing line (→ `review-workflow`). This
* half genuinely needs the plugin installed, which is why the setting is
* worded as an assertion about it.
*
* What never trims is the rule with no replacement anywhere: the no-task
* guardrail. Nothing else states it, and it has to fire before a task exists.
*
* A short `dispatch_share` nudge survives the trim on purpose. That habit was
* already stated in two always-on places and agents still pasted file paths
* into chat, so it's the one tool-routing rule with a demonstrated failure
* history — the toggle tests `create_pr`, not this.
*
* The Autonomous Review rule is shortened for *everyone*, toggle or not, and
* that has nothing to do with the plugin: two thirds of the old block was
* reactive ("after feedback arrives, do X"), and Dispatch already re-injects
* each of those clauses at the moment they apply — see
* `buildLaunchPersonaResponseText` and `reviews/injection-prompts.ts`. What
* remains is the part nothing can inject: the gate the agent must already know
* before it decides it is done, plus a pointer to
* `dispatch_review_list_feedback` — injection is best-effort and is dropped
* when the parent has no live session, so the agent needs one durable way to
* find a review that was submitted while it was down.
*/
export function buildLaunchGuidance(
agentId: string,
Expand All @@ -154,15 +199,29 @@ export function buildLaunchGuidance(
jobRunId?: string;
suggestSessionRename?: boolean;
autoReview?: boolean;
trimmedGuidance?: boolean;
}
): string {
const { agentType, jobRunId, suggestSessionRename, autoReview } = opts;
const {
agentType,
jobRunId,
suggestSessionRename,
autoReview,
trimmedGuidance,
} = opts;
const trimmed =
trimmedGuidance === true &&
agentType !== undefined &&
PLUGIN_CAPABLE_AGENT_TYPES.has(agentType);
const rules: string[] = [];
if (agentType === "cursor") {
rules.push(buildCursorDispatchToolGuidance());
}

if (jobRunId) {
// Not affected by `trimmed`: every rule on this branch is a runtime
// protocol obligation (status, job_log, terminal event) with no
// task-shaped trigger a skill description could key on.
rules.push(
`You are running a Dispatch job run (${jobRunId}). Job agents have a dedicated MCP route — use repo tools when relevant.`
);
Expand All @@ -182,27 +241,43 @@ export function buildLaunchGuidance(
);
if (suggestSessionRename) {
rules.push(
"Name the session. Once the topic of work is clear, call dispatch_rename_session with a short name for that topic, task, or feature — the reason for the session. The name is a stable label describing what the session is about, not a live status update. Rename again if the work shifts substantially to a new topic."
trimmed
? "Name the session with dispatch_rename_session once the topic is clear — a short label for what the session is about, not a live status."
: "Name the session. Once the topic of work is clear, call dispatch_rename_session with a short name for that topic, task, or feature — the reason for the session. The name is a stable label describing what the session is about, not a live status update. Rename again if the work shifts substantially to a new topic."
);
}
rules.push(
"Report status with dispatch_event. Types: working (making progress — includes debugging, fixing test failures, investigating errors), blocked (completely stuck with no further approach to try — NOT for errors or test failures you plan to fix next), waiting_user (need a decision or approval), done (task complete), idle (no-op, just answered a question). Emit working at turn start and when shifting phases. Emit a terminal event before your final response. Your reported status is verified against session activity and auto-corrected when it doesn't match."
);
rules.push(
"Pin key info with dispatch_pin so it surfaces in the sidebar — especially values users may need to copy/paste: URLs, commands, branch names, IDs, tokens, simulator UDIDs. Types: url (dev servers, docs), port (server ports), pr (PR links), filename (key files), code (short snippets, env vars, IDs), string (status, decisions), markdown (short structured summaries), shortcut (a button that sends a prompt back to you when clicked). To delete a stale pin, call dispatch_list_pins then dispatch_delete_pin with its id. For longer artifacts, write a file via dispatch_share and pin a reference."
);
rules.push(
"Offer a shortcut pin when you can name the user's likely next move (launch this, re-run that, pick an approach). Set confirm on destructive ones, and emit waiting_user alongside when the pin answers something blocking you."
);
rules.push(
"Playwright: default headless. Capture at least one screenshot per UI flow via dispatch_share. Call browser_close when done."
trimmed
? "Report status with dispatch_event as you work and before your final response — blocked means genuinely stuck, not an error you're about to fix. Your reported status is verified against session activity and auto-corrected."
: "Report status with dispatch_event. Types: working (making progress — includes debugging, fixing test failures, investigating errors), blocked (completely stuck with no further approach to try — NOT for errors or test failures you plan to fix next), waiting_user (need a decision or approval), done (task complete), idle (no-op, just answered a question). Emit working at turn start and when shifting phases. Emit a terminal event before your final response. Your reported status is verified against session activity and auto-corrected when it doesn't match."
);
if (trimmed) {
// One rule instead of two: surface values, and ask questions, with pins.
// The tool schema carries the types, shortcut mechanics, and deletion.
rules.push(
"Surface important data to the user with dispatch_pin — anything they may need to read or copy — and use shortcut pins to offer a next step or ask them to pick between options."
);
} else {
rules.push(
"Pin key info with dispatch_pin so it surfaces in the sidebar — especially values users may need to copy/paste: URLs, commands, branch names, IDs, tokens, simulator UDIDs. Types: url (dev servers, docs), port (server ports), pr (PR links), filename (key files), code (short snippets, env vars, IDs), string (status, decisions), markdown (short structured summaries), shortcut (a button that sends a prompt back to you when clicked). To delete a stale pin, call dispatch_list_pins then dispatch_delete_pin with its id. For longer artifacts, write a file via dispatch_share and pin a reference."
);
rules.push(
"Offer a shortcut pin when you can name the user's likely next move (launch this, re-run that, pick an approach). Set confirm on destructive ones, and emit waiting_user alongside when the pin answers something blocking you."
);
}
rules.push(
"For pull requests, use the create_pr MCP tool — not built-in PR skills or gh CLI."
trimmed
? "Share artifacts with dispatch_share — screenshots, logs, reports. A file path pasted into chat is not a deliverable."
: "Playwright: default headless. Capture at least one screenshot per UI flow via dispatch_share. Call browser_close when done."
);
if (!trimmed) {
rules.push(
"For pull requests, use the create_pr MCP tool — not built-in PR skills or gh CLI."
);
}
if (autoReview) {
rules.push(
"Autonomous Review is enabled. Before emitting done: commit and push your branch, open a draft PR via create_pr (don't override baseBranch — it defaults correctly), call list_personas, then launch 1 relevant reviewer via dispatch_launch_persona. After launch, do not poll, sleep, call list_agents, or schedule a wakeup; end the turn and let Dispatch inject the structured REVIEW SUBMITTED prompt when ready. If feedback exists, call dispatch_review_list_feedback with the supplied review ID and keep all discussion in item threads via dispatch_review_add_message. After fixing an item, ask the reviewer to verify it instead of resolving it yourself. The reviewer will resolve verified fixes or reply with further instructions. A clean zero-item approval requires no action. Don't emit done until all submitted reviews are resolved."
"Autonomous Review is enabled. Before emitting done: commit and push your branch, open a draft PR via create_pr (don't override baseBranch — it defaults correctly), call list_personas, then launch relevant reviewers via dispatch_launch_persona. Dispatch will guide the rest as it happens. Don't emit done until all submitted reviews are resolved — if a review prompt never arrived, check with dispatch_review_list_feedback."
);
}
}
Expand Down Expand Up @@ -240,6 +315,7 @@ type BuildAgentCommandOptions = {
jobRunId?: string;
suggestSessionRename?: boolean;
autoReview?: boolean;
trimmedGuidance?: boolean;
initialPrompt?: string;
personalityPrompt?: string | null;
model?: string;
Expand All @@ -259,6 +335,7 @@ export function buildAgentCommand(
jobRunId,
suggestSessionRename,
autoReview,
trimmedGuidance,
initialPrompt,
personalityPrompt,
model,
Expand All @@ -270,6 +347,7 @@ export function buildAgentCommand(
jobRunId,
suggestSessionRename,
autoReview,
trimmedGuidance,
});

const userLocalBin = process.env.HOME
Expand Down
38 changes: 38 additions & 0 deletions apps/server/src/launch-guidance-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { Pool } from "pg";

import { getSetting, setSetting } from "./db/settings.js";

/**
* Whether launch guidance is trimmed to the rules the Dispatch plugin's
* skills do NOT cover. Off by default — every agent gets the full ruleset,
* which is correct for anyone who hasn't installed the plugin.
*
* This is a user *assertion*, not detection: the CLIs own plugin install
* state (`~/.claude/settings.json`, `~/.codex/config.toml`) and Dispatch
* never reads it. Turning this on without the plugin installed silently
* drops guidance with nothing replacing it, which is why the setting copy
* says so and the default is off.
*
* Read once per agent launch (a cold path that already hits the DB), so
* there's no cache here — unlike injection-hold, which is consulted on
* every injection. Guidance is composed at launch, so a flip only affects
* agents started afterwards.
*/
const TRIMMED_LAUNCH_GUIDANCE_KEY = "trimmed_launch_guidance_enabled";

export async function isTrimmedLaunchGuidanceEnabled(
pool: Pool
): Promise<boolean> {
return (await getSetting(pool, TRIMMED_LAUNCH_GUIDANCE_KEY)) === "true";
}

export async function setTrimmedLaunchGuidanceEnabled(
pool: Pool,
enabled: boolean
): Promise<void> {
await setSetting(
pool,
TRIMMED_LAUNCH_GUIDANCE_KEY,
enabled ? "true" : "false"
);
}
20 changes: 20 additions & 0 deletions apps/server/src/routes/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import {
loadInjectionHoldEnabled,
setInjectionHoldEnabled,
} from "../injection-hold-settings.js";
import {
isTrimmedLaunchGuidanceEnabled,
setTrimmedLaunchGuidanceEnabled,
} from "../launch-guidance-settings.js";
import { JobService } from "../jobs/service.js";
import {
AGENT_TYPES,
Expand Down Expand Up @@ -435,6 +439,22 @@ export async function registerSystemRoutes(
return { enabled: body.enabled };
});

app.get("/api/v1/app/settings/launch-guidance-trim", async () => {
return { enabled: await isTrimmedLaunchGuidanceEnabled(deps.pool) };
});

app.post(
"/api/v1/app/settings/launch-guidance-trim",
async (request, reply) => {
const body = request.body as { enabled?: unknown } | null;
if (typeof body?.enabled !== "boolean") {
return reply.code(400).send({ error: "enabled must be a boolean." });
}
await setTrimmedLaunchGuidanceEnabled(deps.pool, body.enabled);
return { enabled: body.enabled };
}
);

app.get("/api/v1/app/settings/cross-repo-messaging", async () => {
return { enabled: await isCrossRepoMessagingEnabled(deps.pool) };
});
Expand Down
16 changes: 12 additions & 4 deletions apps/server/test/db/agent-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,14 +611,22 @@ describe("AgentManager", () => {
`/tmp/dispatch_setup_${agent.id}.sh`,
"utf-8"
);
// The proactive gates: nothing can inject these at the right moment,
// because the moment is the agent deciding it's done.
expect(setupScript).toContain("Autonomous Review is enabled");
expect(setupScript).toContain("list_personas");
expect(setupScript).toContain("dispatch_launch_persona");
// No apostrophes: the guidance is shell-escaped into this script.
expect(setupScript).toContain("until all submitted reviews are resolved");
// Injection is best-effort and dropped when the agent has no session,
// so the recovery pointer stays durable.
expect(setupScript).toContain("dispatch_review_list_feedback");
expect(setupScript).toContain("structured REVIEW SUBMITTED prompt");
expect(setupScript).toContain("launch 1 relevant reviewer");
expect(setupScript).toContain("do not poll, sleep");
expect(setupScript).toContain("ask the reviewer to verify it");
// The rest of the reactive half is delivered by injection when it
// applies (buildLaunchPersonaResponseText /
// reviews/injection-prompts.ts), so it no longer rides along.
expect(setupScript).not.toContain("structured REVIEW SUBMITTED prompt");
expect(setupScript).not.toContain("ask the reviewer to verify it");
expect(setupScript).not.toContain("zero-item approval");
expect(setupScript).not.toContain(
"set each outcome with dispatch_review_resolve"
);
Expand Down
Loading
Loading