From e4535951efe599899eae65cd1cedbcf2d8a22206 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 13 Aug 2026 19:13:53 -0600 Subject: [PATCH 1/7] Trim launch guidance to the plugin's skills, behind a setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dispatch plugin (PR #952) ships 11 skills, three of which cover ground launchGuidance already covers unconditionally: ui-validation and sharing for the Playwright rule, review-workflow for create_pr and the Autonomous Review block. A skill's body only loads when its description matches the situation, so anything that must fire before a task exists — the no-task guardrail, session naming, dispatch_event, pin surfacing — cannot become a skill and is never trimmed. Adds a server-wide setting (Settings → Agents → Launch guidance, off by default) that switches buildLaunchGuidance between the full ruleset and a trimmed one. It's a user assertion, not detection: Dispatch cannot see whether the CLI has the plugin installed, so the copy says what turning it on without the plugin costs, and only Claude Code and Codex agents — the plugin's platforms — are ever trimmed. The two tool-routing lines keep a short always-on nudge even when trimmed. dispatch_share was already stated in two always-on places and still got skipped, so moving those habits entirely onto a match-triggered skill is the riskiest part of the trim and is the thing to watch during the soak. The job-run branch is untouched — every rule there is a runtime protocol obligation with no task-shaped trigger. Measured live on a dev stack, same endpoint, autoReview on: 2678 chars full -> 2162 trimmed. Co-Authored-By: Claude Opus 5 --- apps/server/src/agents/manager.ts | 5 + .../server/src/agents/tmux/command-builder.ts | 50 +++++++- apps/server/src/launch-guidance-settings.ts | 38 ++++++ apps/server/src/routes/system.ts | 20 +++ apps/server/test/tmux-command-builder.test.ts | 118 ++++++++++++++++++ .../app/launch-guidance-settings.tsx | 105 ++++++++++++++++ apps/web/src/components/app/settings-pane.tsx | 4 + 7 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/launch-guidance-settings.ts create mode 100644 apps/web/src/components/app/launch-guidance-settings.tsx diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts index 03f77c33..d420a0e3 100644 --- a/apps/server/src/agents/manager.ts +++ b/apps/server/src/agents/manager.ts @@ -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"; @@ -696,6 +697,7 @@ export class AgentManager { opts.persona || opts.jobRunId || role === "assisted_update" ? null : await getActivePersonality(this.pool); + const trimmedGuidance = await isTrimmedLaunchGuidanceEnabled(this.pool); const agentCommand = buildAgentCommand( this.config, @@ -713,6 +715,7 @@ export class AgentManager { jobRunId: opts.jobRunId, }), autoReview: !opts.persona && !opts.jobRunId && opts.autoReview, + trimmedGuidance, initialPrompt: startupPrompt, personalityPrompt: personality?.prompt ?? null, model, @@ -906,6 +909,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, @@ -922,6 +926,7 @@ export class AgentManager { persona: agent.persona, }), autoReview: !agent.persona && (agent.autoReview ?? false), + trimmedGuidance, personalityPrompt: personality?.prompt ?? null, model: agent.model ?? undefined, } diff --git a/apps/server/src/agents/tmux/command-builder.ts b/apps/server/src/agents/tmux/command-builder.ts index 3a389360..de69a0e8 100644 --- a/apps/server/src/agents/tmux/command-builder.ts +++ b/apps/server/src/agents/tmux/command-builder.ts @@ -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 = new Set([ + "claude", + "codex", +]); + /** * Pull a `--append-system-prompt ` pair out of an arg list (codex / * opencode put system prompts in their own flag). Claude doesn't need this @@ -146,6 +157,18 @@ export function buildStartupPrompt( /** * Build the numbered launch guidance text shared by all CLI agent types. + * + * `trimmedGuidance` shortens the rules the Dispatch plugin's skills now cover + * in depth (Playwright/`dispatch_share`, and the Autonomous Review workflow). + * It never touches the rules that have no task to match against — no-task + * guardrail, session naming, `dispatch_event`, pin surfacing — because a skill + * only loads when its description matches the situation, so always-on rules + * cannot become skills without silently stopping working. + * + * The two tool-routing lines (`dispatch_share`, `create_pr`) keep a short + * always-on nudge even when trimmed: those habits were already ignored when + * stated in two always-on places, so moving them entirely onto a + * match-triggered skill is the riskiest part of the trim. */ export function buildLaunchGuidance( agentId: string, @@ -154,15 +177,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.` ); @@ -195,14 +232,18 @@ export function buildLaunchGuidance( "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 + ? "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." ); 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." + trimmed + ? "Autonomous Review is enabled. Before emitting done: commit and push, open a draft PR via create_pr, then launch a reviewer via dispatch_launch_persona and end the turn — do not poll, sleep, or schedule a wakeup; Dispatch injects the review prompt when it's ready. 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 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." ); } } @@ -240,6 +281,7 @@ type BuildAgentCommandOptions = { jobRunId?: string; suggestSessionRename?: boolean; autoReview?: boolean; + trimmedGuidance?: boolean; initialPrompt?: string; personalityPrompt?: string | null; model?: string; @@ -259,6 +301,7 @@ export function buildAgentCommand( jobRunId, suggestSessionRename, autoReview, + trimmedGuidance, initialPrompt, personalityPrompt, model, @@ -270,6 +313,7 @@ export function buildAgentCommand( jobRunId, suggestSessionRename, autoReview, + trimmedGuidance, }); const userLocalBin = process.env.HOME diff --git a/apps/server/src/launch-guidance-settings.ts b/apps/server/src/launch-guidance-settings.ts new file mode 100644 index 00000000..c8dc4d7d --- /dev/null +++ b/apps/server/src/launch-guidance-settings.ts @@ -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 { + return (await getSetting(pool, TRIMMED_LAUNCH_GUIDANCE_KEY)) === "true"; +} + +export async function setTrimmedLaunchGuidanceEnabled( + pool: Pool, + enabled: boolean +): Promise { + await setSetting( + pool, + TRIMMED_LAUNCH_GUIDANCE_KEY, + enabled ? "true" : "false" + ); +} diff --git a/apps/server/src/routes/system.ts b/apps/server/src/routes/system.ts index 71d26082..cb989c0b 100644 --- a/apps/server/src/routes/system.ts +++ b/apps/server/src/routes/system.ts @@ -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, @@ -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) }; }); diff --git a/apps/server/test/tmux-command-builder.test.ts b/apps/server/test/tmux-command-builder.test.ts index bed5c2d2..59debec6 100644 --- a/apps/server/test/tmux-command-builder.test.ts +++ b/apps/server/test/tmux-command-builder.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, it, expect, vi } from "vitest"; import type { AppConfig } from "../src/config.js"; import { buildAgentCommand, + buildLaunchGuidance, buildStartupPrompt, normalizeAgentArgsForType, } from "../src/agents/tmux/command-builder.js"; @@ -708,3 +709,120 @@ describe("buildAgentCommand — host-env reads (process.env / process.platform)" expect(cmd).not.toContain("NODE_EXTRA_CA_CERTS"); }); }); + +describe("buildLaunchGuidance — trimmed variant", () => { + const PLAYWRIGHT_RULE = "Playwright: default headless."; + const SHARE_NUDGE = "Share artifacts with dispatch_share"; + const CREATE_PR_RULE = "use the create_pr MCP tool"; + const REVIEW_DETAIL = "structured REVIEW SUBMITTED prompt"; + + // Rules with no task-shaped trigger for a skill description to match on — + // these must survive the trim in every variant. + const ALWAYS_ON = [ + "No task, no work.", + "Name the session.", + "Report status with dispatch_event.", + "Pin key info with dispatch_pin", + "Offer a shortcut pin", + ]; + + function guidance(opts: Parameters[1]): string { + return buildLaunchGuidance(AGENT_ID, opts); + } + + it("keeps the full ruleset when the setting is off", () => { + const text = guidance({ agentType: "claude", suggestSessionRename: true }); + expect(text).toContain(PLAYWRIGHT_RULE); + expect(text).not.toContain(SHARE_NUDGE); + expect(text).toContain(CREATE_PR_RULE); + }); + + it("swaps the Playwright rule for a short dispatch_share nudge when trimmed", () => { + const text = guidance({ + agentType: "claude", + suggestSessionRename: true, + trimmedGuidance: true, + }); + expect(text).not.toContain(PLAYWRIGHT_RULE); + expect(text).toContain(SHARE_NUDGE); + }); + + it("keeps the create_pr tool-routing rule verbatim when trimmed", () => { + const text = guidance({ agentType: "claude", trimmedGuidance: true }); + expect(text).toContain(CREATE_PR_RULE); + }); + + it("shortens the Autonomous Review block but keeps its runtime protocol", () => { + const full = guidance({ agentType: "claude", autoReview: true }); + const trimmed = guidance({ + agentType: "claude", + autoReview: true, + trimmedGuidance: true, + }); + expect(full).toContain(REVIEW_DETAIL); + expect(trimmed).toContain("Autonomous Review is enabled"); + expect(trimmed).not.toContain(REVIEW_DETAIL); + // The bits Dispatch's runtime depends on stay in the always-on text. + expect(trimmed).toContain("do not poll"); + expect(trimmed).toContain("create_pr"); + expect(trimmed).toContain("dispatch_launch_persona"); + expect(trimmed.length).toBeLessThan(full.length); + }); + + it("omits the Autonomous Review rule entirely when autoReview is off", () => { + const text = guidance({ agentType: "claude", trimmedGuidance: true }); + expect(text).not.toContain("Autonomous Review is enabled"); + }); + + it("never trims the always-on rules", () => { + const text = guidance({ + agentType: "claude", + suggestSessionRename: true, + autoReview: true, + trimmedGuidance: true, + }); + for (const rule of ALWAYS_ON) expect(text).toContain(rule); + }); + + it("ignores the setting for agent types with no Dispatch plugin", () => { + for (const agentType of ["opencode", "cursor"] as const) { + const text = guidance({ + agentType, + autoReview: true, + trimmedGuidance: true, + }); + expect(text).toContain(PLAYWRIGHT_RULE); + expect(text).toContain(REVIEW_DETAIL); + } + }); + + it("applies to codex as well as claude", () => { + const text = guidance({ agentType: "codex", trimmedGuidance: true }); + expect(text).toContain(SHARE_NUDGE); + expect(text).not.toContain(PLAYWRIGHT_RULE); + }); + + it("leaves job-run guidance identical — every rule there is protocol", () => { + const opts = { + agentType: "claude", + jobRunId: "run_1", + suggestSessionRename: true, + } as const; + expect(guidance({ ...opts, trimmedGuidance: true })).toBe(guidance(opts)); + }); + + it("threads the setting through buildAgentCommand", () => { + const cmd = buildAgentCommand( + baseConfig, + "claude", + "standard", + [], + "/tmp/media", + SESSION, + false, + { autoReview: true, trimmedGuidance: true } + ); + expect(cmd).toContain(SHARE_NUDGE); + expect(cmd).not.toContain(PLAYWRIGHT_RULE); + }); +}); diff --git a/apps/web/src/components/app/launch-guidance-settings.tsx b/apps/web/src/components/app/launch-guidance-settings.tsx new file mode 100644 index 00000000..7090ff97 --- /dev/null +++ b/apps/web/src/components/app/launch-guidance-settings.tsx @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { Checkbox } from "@/components/ui/checkbox"; +import { api } from "@/lib/api"; + +type LaunchGuidanceTrimResponse = { enabled: boolean }; + +const ENDPOINT = "/api/v1/app/settings/launch-guidance-trim"; + +/** + * Toggle for the trimmed launch-guidance variant. Enforced server-side (the + * guidance string is composed at agent launch), so the server is the source of + * truth: GET on mount, POST only on explicit user toggle. Off by default. + * + * This is a user assertion, not detection — Dispatch cannot see whether the + * plugin is installed in the CLI, so the copy has to say what turning it on + * without the plugin costs. + */ +export function LaunchGuidanceSettings(): JSX.Element { + const [enabled, setEnabled] = useState(false); + const [error, setError] = useState(""); + const latestReq = useRef(0); + const confirmedValue = useRef(false); + + useEffect(() => { + const seq = (latestReq.current += 1); + void api(ENDPOINT) + .then((data) => { + if (seq === latestReq.current) { + confirmedValue.current = data.enabled; + setEnabled(data.enabled); + } + }) + .catch(() => { + if (seq === latestReq.current) { + setError("Failed to load launch guidance setting."); + } + }); + }, []); + + const handleToggle = useCallback((next: boolean) => { + const seq = (latestReq.current += 1); + setError(""); + setEnabled(next); + void api(ENDPOINT, { + method: "POST", + body: JSON.stringify({ enabled: next }), + }) + .then(() => { + if (seq === latestReq.current) confirmedValue.current = next; + }) + .catch((err) => { + if (seq !== latestReq.current) return; + setEnabled(confirmedValue.current); + setError( + err instanceof Error + ? err.message + : "Failed to save launch guidance setting." + ); + }); + }, []); + + return ( +
+
+ Launch guidance +
+

+ Dispatch injects a set of startup rules into every agent. The Dispatch + plugin covers some of the same ground as discoverable skills, so those + rules can be shortened for agents that have it installed. Applies to all + agents on this Dispatch server. +

+
+ +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/app/settings-pane.tsx b/apps/web/src/components/app/settings-pane.tsx index 0f972e42..da3653e8 100644 --- a/apps/web/src/components/app/settings-pane.tsx +++ b/apps/web/src/components/app/settings-pane.tsx @@ -5,6 +5,7 @@ import { AppearanceSettings } from "@/components/app/appearance-settings"; import { BrowserExtensionSettings } from "@/components/app/browser-extension-settings"; import { CrossRepoMessagingSettings } from "@/components/app/cross-repo-messaging-settings"; import { InjectionHoldSettings } from "@/components/app/injection-hold-settings"; +import { LaunchGuidanceSettings } from "@/components/app/launch-guidance-settings"; import { IdeSettings } from "@/components/app/ide-settings"; import { InstanceNameSettings } from "@/components/app/instance-name-settings"; import { DocsContent, DOCS_SECTION_NAV } from "@/components/app/docs-pane"; @@ -207,6 +208,9 @@ export function SettingsContent({ onChange={onEnabledIdesChange} /> +
+ +
From e00766cb1b4ba4bbdb4fe9de5feb67954988ca61 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 13 Aug 2026 19:25:33 -0600 Subject: [PATCH 2/7] Address review: job-run guard, unknown-state gate, a11y, copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - manager.ts: skip the settings read for job runs. Their ruleset is never trimmed, so an unchanged launch path shouldn't gain a dependency on a query that can fail (DB outage, or the pre-migration settings window). - Checkbox stays disabled until the GET lands, and after a failed one. An unread value must not render as a confirmed "off" — the wrong belief here silently drops guidance from every agent launched afterwards. - Explicit short aria-label plus aria-describedby, so the accessible name isn't the whole ~170-word detail block. - Chain the POSTs. Sequence-guarding local state left two quick toggles able to land out of order at the server. - Lead the description with the consequence of asserting an install you don't have, instead of burying it after the implementation caveats. Verified live: with the trim on, a job-run agent still gets the untouched 620-char job ruleset (read from its own process argv); disabled/enabled gating, aria wiring, and last-write-wins confirmed in Playwright. Co-Authored-By: Claude Opus 5 --- apps/server/src/agents/manager.ts | 6 +- .../app/launch-guidance-settings.tsx | 78 +++++++++++-------- 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts index d420a0e3..cb816cfe 100644 --- a/apps/server/src/agents/manager.ts +++ b/apps/server/src/agents/manager.ts @@ -697,7 +697,11 @@ export class AgentManager { opts.persona || opts.jobRunId || role === "assisted_update" ? null : await getActivePersonality(this.pool); - const trimmedGuidance = await isTrimmedLaunchGuidanceEnabled(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, diff --git a/apps/web/src/components/app/launch-guidance-settings.tsx b/apps/web/src/components/app/launch-guidance-settings.tsx index 7090ff97..bc2e10d4 100644 --- a/apps/web/src/components/app/launch-guidance-settings.tsx +++ b/apps/web/src/components/app/launch-guidance-settings.tsx @@ -6,6 +6,7 @@ import { api } from "@/lib/api"; type LaunchGuidanceTrimResponse = { enabled: boolean }; const ENDPOINT = "/api/v1/app/settings/launch-guidance-trim"; +const DETAIL_ID = "launch-guidance-trim-detail"; /** * Toggle for the trimmed launch-guidance variant. Enforced server-side (the @@ -13,23 +14,30 @@ const ENDPOINT = "/api/v1/app/settings/launch-guidance-trim"; * truth: GET on mount, POST only on explicit user toggle. Off by default. * * This is a user assertion, not detection — Dispatch cannot see whether the - * plugin is installed in the CLI, so the copy has to say what turning it on - * without the plugin costs. + * plugin is installed in the CLI, so the copy leads with what turning it on + * without the plugin costs, and the checkbox stays disabled until the GET + * lands. An unread value must not render as a confirmed "off": the wrong + * belief here silently drops guidance from every agent launched afterwards. */ export function LaunchGuidanceSettings(): JSX.Element { const [enabled, setEnabled] = useState(false); + const [loaded, setLoaded] = useState(false); const [error, setError] = useState(""); const latestReq = useRef(0); const confirmedValue = useRef(false); + // Writes are chained, not just sequence-guarded: two quick toggles could + // otherwise land at the server out of order and leave it on the older + // value while the UI showed the newer one. + const pendingWrite = useRef>(Promise.resolve()); useEffect(() => { const seq = (latestReq.current += 1); void api(ENDPOINT) .then((data) => { - if (seq === latestReq.current) { - confirmedValue.current = data.enabled; - setEnabled(data.enabled); - } + if (seq !== latestReq.current) return; + confirmedValue.current = data.enabled; + setEnabled(data.enabled); + setLoaded(true); }) .catch(() => { if (seq === latestReq.current) { @@ -42,22 +50,26 @@ export function LaunchGuidanceSettings(): JSX.Element { const seq = (latestReq.current += 1); setError(""); setEnabled(next); - void api(ENDPOINT, { - method: "POST", - body: JSON.stringify({ enabled: next }), - }) - .then(() => { - if (seq === latestReq.current) confirmedValue.current = next; - }) - .catch((err) => { - if (seq !== latestReq.current) return; - setEnabled(confirmedValue.current); - setError( - err instanceof Error - ? err.message - : "Failed to save launch guidance setting." - ); - }); + pendingWrite.current = pendingWrite.current + .catch(() => undefined) + .then(() => + api(ENDPOINT, { + method: "POST", + body: JSON.stringify({ enabled: next }), + }) + .then(() => { + if (seq === latestReq.current) confirmedValue.current = next; + }) + .catch((err) => { + if (seq !== latestReq.current) return; + setEnabled(confirmedValue.current); + setError( + err instanceof Error + ? err.message + : "Failed to save launch guidance setting." + ); + }) + ); }, []); return ( @@ -75,22 +87,26 @@ export function LaunchGuidanceSettings(): JSX.Element { From 1869d52fe6ba509dfb97929e58a85766947d2a0d Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 13 Aug 2026 19:43:53 -0600 Subject: [PATCH 4/7] Keep a durable review-recovery pointer in the launch guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review injection is best-effort: sendPromptBestEffort swallows the failure when the parent agent has no live session, and nothing replays it on the next launch. Under the shortened rule an agent could then be left knowing reviews block done, but not how to find one submitted while it was down. Adds "if a review prompt never arrived, check with dispatch_review_list_feedback" — 55 chars against the 485 the shortening saves, and it restores the self-recovery path the old block provided. Persisting and replaying dropped injections is the real fix and is a separate change. Verified in the live process argv: the rule is 422 chars and carries the pointer. Co-Authored-By: Claude Opus 5 --- apps/server/src/agents/tmux/command-builder.ts | 7 +++++-- apps/server/test/db/agent-manager.test.ts | 10 ++++++---- apps/server/test/tmux-command-builder.test.ts | 6 ++++-- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/server/src/agents/tmux/command-builder.ts b/apps/server/src/agents/tmux/command-builder.ts index 7734424a..cca67d7e 100644 --- a/apps/server/src/agents/tmux/command-builder.ts +++ b/apps/server/src/agents/tmux/command-builder.ts @@ -177,7 +177,10 @@ export function buildStartupPrompt( * 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. + * 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, @@ -252,7 +255,7 @@ export function buildLaunchGuidance( } 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 relevant reviewers via dispatch_launch_persona. Dispatch will guide the rest as it happens. 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." ); } } diff --git a/apps/server/test/db/agent-manager.test.ts b/apps/server/test/db/agent-manager.test.ts index 53007843..96924744 100644 --- a/apps/server/test/db/agent-manager.test.ts +++ b/apps/server/test/db/agent-manager.test.ts @@ -618,10 +618,12 @@ describe("AgentManager", () => { 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"); - // The reactive half is delivered by injection when it applies - // (buildLaunchPersonaResponseText / reviews/injection-prompts.ts), so - // it no longer rides along in every launch. - expect(setupScript).not.toContain("dispatch_review_list_feedback"); + // 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"); + // 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"); diff --git a/apps/server/test/tmux-command-builder.test.ts b/apps/server/test/tmux-command-builder.test.ts index c136f5b7..9bd35314 100644 --- a/apps/server/test/tmux-command-builder.test.ts +++ b/apps/server/test/tmux-command-builder.test.ts @@ -718,7 +718,6 @@ describe("buildLaunchGuidance — trimmed variant", () => { // everyone, toggle or not. const REACTIVE_REVIEW_CLAUSES = [ "structured REVIEW SUBMITTED prompt", - "dispatch_review_list_feedback", "ask the reviewer to verify it", "zero-item approval", ]; @@ -774,7 +773,10 @@ describe("buildLaunchGuidance — trimmed variant", () => { expect(text).toContain( "Don't emit done until all submitted reviews are resolved" ); - // The reactive half is delivered by injection instead. + // Injection is best-effort, so one durable way to find a review + // submitted while this agent was down has to survive. + expect(text).toContain("dispatch_review_list_feedback"); + // The rest of the reactive half is delivered by injection instead. for (const clause of REACTIVE_REVIEW_CLAUSES) { expect(text).not.toContain(clause); } From 057720e83340ba12b8bb0cc5c0d5116801c49dec Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 13 Aug 2026 19:59:43 -0600 Subject: [PATCH 5/7] Trim the rules the MCP tool schemas already document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass only trimmed what a plugin skill replaced, which left the verbose rules untouched and made the toggle nearly a no-op. The bigger duplication is with the 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 in launch guidance repeated a description the agent already has, in every session, whether or not the flow ever comes up. Trimmed now means short pointers: name the session, report status, and surface data or ask questions with pins — one line each, with the two pin rules folded into one. Kept the two things no schema states: that blocked means genuinely stuck rather than an error you're about to fix, and that reported status is verified and auto-corrected. The no-task guardrail stays verbatim; nothing else states it anywhere. Note this half doesn't actually depend on the plugin — the tool schemas ship to every agent. Only the browser-validation and pull-request rules need the skills, so the setting copy now separates the two. Measured live, same endpoint, autoReview on: 2590 -> 1336 chars (-48%). Co-Authored-By: Claude Opus 5 --- .../server/src/agents/tmux/command-builder.ts | 52 ++++++++++++----- apps/server/test/tmux-command-builder.test.ts | 58 +++++++++++++++---- .../app/launch-guidance-settings.tsx | 29 +++++----- 3 files changed, 100 insertions(+), 39 deletions(-) diff --git a/apps/server/src/agents/tmux/command-builder.ts b/apps/server/src/agents/tmux/command-builder.ts index cca67d7e..9bf08610 100644 --- a/apps/server/src/agents/tmux/command-builder.ts +++ b/apps/server/src/agents/tmux/command-builder.ts @@ -158,13 +158,23 @@ export function buildStartupPrompt( /** * Build the numbered launch guidance text shared by all CLI agent types. * - * `trimmedGuidance` gates only the rules whose replacement is a *plugin skill*: - * the Playwright methodology (→ `ui-validation` + `sharing`) and the `create_pr` - * routing line (→ `review-workflow`). It never touches the rules that have no - * task to match against — no-task guardrail, session naming, `dispatch_event`, - * pin surfacing — because a skill only loads when its description matches the - * situation, so always-on rules cannot become skills without silently stopping - * working. + * `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 @@ -231,18 +241,30 @@ 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." + 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( trimmed ? "Share artifacts with dispatch_share — screenshots, logs, reports. A file path pasted into chat is not a deliverable." diff --git a/apps/server/test/tmux-command-builder.test.ts b/apps/server/test/tmux-command-builder.test.ts index 9bd35314..82c0fe4b 100644 --- a/apps/server/test/tmux-command-builder.test.ts +++ b/apps/server/test/tmux-command-builder.test.ts @@ -722,14 +722,13 @@ describe("buildLaunchGuidance — trimmed variant", () => { "zero-item approval", ]; - // Rules with no task-shaped trigger for a skill description to match on — - // these must survive the trim in every variant. - const ALWAYS_ON = [ - "No task, no work.", - "Name the session.", - "Report status with dispatch_event.", - "Pin key info with dispatch_pin", - "Offer a shortcut pin", + // Detail that lives in the MCP tool schemas — restating it in every + // session is what the trim removes. + const SCHEMA_DUPLICATED = [ + "Types: url (dev servers, docs)", + "waiting_user (need a decision or approval)", + "call dispatch_list_pins then dispatch_delete_pin", + "Set confirm on destructive ones", ]; function guidance(opts: Parameters[1]): string { @@ -797,14 +796,53 @@ describe("buildLaunchGuidance — trimmed variant", () => { expect(text).not.toContain("Autonomous Review is enabled"); }); - it("never trims the always-on rules", () => { + it("keeps every rule's subject, just shorter", () => { const text = guidance({ agentType: "claude", suggestSessionRename: true, autoReview: true, trimmedGuidance: true, }); - for (const rule of ALWAYS_ON) expect(text).toContain(rule); + // The no-task guardrail has no replacement anywhere — verbatim. + expect(text).toContain("No task, no work."); + // The rest survive as pointers: still named, no longer explained. + for (const tool of [ + "dispatch_rename_session", + "dispatch_event", + "dispatch_pin", + "shortcut pins", + "dispatch_share", + ]) { + expect(text).toContain(tool); + } + }); + + it("drops the detail the MCP tool schemas already carry", () => { + const full = guidance({ agentType: "claude", suggestSessionRename: true }); + const text = guidance({ + agentType: "claude", + suggestSessionRename: true, + trimmedGuidance: true, + }); + for (const detail of SCHEMA_DUPLICATED) { + expect(full).toContain(detail); + expect(text).not.toContain(detail); + } + }); + + it("keeps the dispatch_event misuse guard, which no schema states", () => { + const text = guidance({ agentType: "claude", trimmedGuidance: true }); + expect(text).toContain("blocked means genuinely stuck"); + expect(text).toContain("auto-corrected"); + }); + + it("folds the two pin rules into one", () => { + const full = guidance({ agentType: "claude" }); + const text = guidance({ agentType: "claude", trimmedGuidance: true }); + const pinRules = (s: string) => + s.split("\n").filter((line) => /pin/i.test(line)).length; + expect(pinRules(full)).toBe(2); + expect(pinRules(text)).toBe(1); }); it("ignores the setting for agent types with no Dispatch plugin", () => { diff --git a/apps/web/src/components/app/launch-guidance-settings.tsx b/apps/web/src/components/app/launch-guidance-settings.tsx index 36a51518..82669666 100644 --- a/apps/web/src/components/app/launch-guidance-settings.tsx +++ b/apps/web/src/components/app/launch-guidance-settings.tsx @@ -78,10 +78,10 @@ export function LaunchGuidanceSettings(): JSX.Element { Launch guidance

- Dispatch injects a set of startup rules into every agent. The Dispatch - plugin covers some of the same ground as discoverable skills, so those - rules can be shortened for agents that have it installed. Applies to all - agents on this Dispatch server. + Dispatch injects a set of startup rules into every agent. Most of their + detail is already in the MCP tool descriptions, and the rest is in the + Dispatch plugin's skills — so the rules can be short pointers + instead. Applies to all agents on this Dispatch server.

From 6f939000e05371da8ea2687736497e829ac35187 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sat, 15 Aug 2026 20:11:35 -0600 Subject: [PATCH 6/7] Pin the "setting off is inert" property with a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diffed the whole option matrix against the branch point to answer how confident we are that an untouched setting changes nothing: 48 of 64 combinations are byte-identical, and all 16 that differ are autoReview=true with the Autonomous Review rule as the only changed line. Every job-run combination is identical. This test guards the half of that which stays true forever: trimmedGuidance false and undefined must produce identical guidance, for every agent type and both branches. (The other half — that the deliberate Autonomous Review change is the only remaining difference — is a one-time property of this PR, not an invariant.) Co-Authored-By: Claude Opus 5 --- apps/server/test/tmux-command-builder.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/server/test/tmux-command-builder.test.ts b/apps/server/test/tmux-command-builder.test.ts index 82c0fe4b..d76df314 100644 --- a/apps/server/test/tmux-command-builder.test.ts +++ b/apps/server/test/tmux-command-builder.test.ts @@ -735,6 +735,26 @@ describe("buildLaunchGuidance — trimmed variant", () => { return buildLaunchGuidance(AGENT_ID, opts); } + it("is inert when the setting is off, however it's spelled", () => { + // An unset settings row reads as `false`, and callers that predate the + // option pass nothing at all. Both must leave guidance untouched, for + // every agent type and both branches. + for (const agentType of ["claude", "codex", "opencode", "cursor"] as const) + for (const jobRunId of [undefined, "run_1"]) + for (const suggestSessionRename of [false, true]) + for (const autoReview of [false, true]) { + const opts = { + agentType, + jobRunId, + suggestSessionRename, + autoReview, + }; + expect(guidance({ ...opts, trimmedGuidance: false })).toBe( + guidance(opts) + ); + } + }); + it("keeps the full ruleset when the setting is off", () => { const text = guidance({ agentType: "claude", suggestSessionRename: true }); expect(text).toContain(PLAYWRIGHT_RULE); From eac79d11809661c88d0a40b981dca25e220e2763 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sat, 15 Aug 2026 20:19:54 -0600 Subject: [PATCH 7/7] Cut the launch-guidance setting copy down to a checkbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rounds of review feedback each asked for something to be said more prominently, and the result was a bordered box nested inside the section, holding a ~170-word paragraph nobody would read. Brad called it: no nested container, one short sentence. Now a two-sentence section description and a plain checkbox row. The plugin requirement survives as a requirement rather than a warning paragraph; the rest of the explanation belongs in the PR, not the settings pane. Dropping the long detail block also removes the need for the aria-describedby wiring — the accessible name is now just "Use short startup rules". Behavior is unchanged: still disabled until the GET lands, still chains writes, still an explicit user assertion. Co-Authored-By: Claude Opus 5 --- .../app/launch-guidance-settings.tsx | 55 ++++++------------- 1 file changed, 17 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/app/launch-guidance-settings.tsx b/apps/web/src/components/app/launch-guidance-settings.tsx index 82669666..5b22930f 100644 --- a/apps/web/src/components/app/launch-guidance-settings.tsx +++ b/apps/web/src/components/app/launch-guidance-settings.tsx @@ -6,7 +6,6 @@ import { api } from "@/lib/api"; type LaunchGuidanceTrimResponse = { enabled: boolean }; const ENDPOINT = "/api/v1/app/settings/launch-guidance-trim"; -const DETAIL_ID = "launch-guidance-trim-detail"; /** * Toggle for the trimmed launch-guidance variant. Enforced server-side (the @@ -14,10 +13,11 @@ const DETAIL_ID = "launch-guidance-trim-detail"; * truth: GET on mount, POST only on explicit user toggle. Off by default. * * This is a user assertion, not detection — Dispatch cannot see whether the - * plugin is installed in the CLI, so the copy leads with what turning it on - * without the plugin costs, and the checkbox stays disabled until the GET - * lands. An unread value must not render as a confirmed "off": the wrong - * belief here silently drops guidance from every agent launched afterwards. + * plugin is installed in the CLI, so the copy states it as a requirement. + * + * The checkbox stays disabled until the GET lands: an unread value must not + * render as a confirmed "off", since the wrong belief here silently changes + * the guidance of every agent launched afterwards. */ export function LaunchGuidanceSettings(): JSX.Element { const [enabled, setEnabled] = useState(false); @@ -78,40 +78,19 @@ export function LaunchGuidanceSettings(): JSX.Element { Launch guidance

- Dispatch injects a set of startup rules into every agent. Most of their - detail is already in the MCP tool descriptions, and the rest is in the - Dispatch plugin's skills — so the rules can be short pointers - instead. Applies to all agents on this Dispatch server. + Shorten the startup rules injected into new Claude Code and Codex + agents. Requires the Dispatch plugin — its skills and the MCP tool + descriptions carry the detail the rules drop.

-
- -
+ {error ? (

{error}