diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts index 03f77c33..cb816cfe 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,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, @@ -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, @@ -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, @@ -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, } diff --git a/apps/server/src/agents/tmux/command-builder.ts b/apps/server/src/agents/tmux/command-builder.ts index 3a389360..9bf08610 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,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, @@ -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.` ); @@ -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." ); } } @@ -240,6 +315,7 @@ type BuildAgentCommandOptions = { jobRunId?: string; suggestSessionRename?: boolean; autoReview?: boolean; + trimmedGuidance?: boolean; initialPrompt?: string; personalityPrompt?: string | null; model?: string; @@ -259,6 +335,7 @@ export function buildAgentCommand( jobRunId, suggestSessionRename, autoReview, + trimmedGuidance, initialPrompt, personalityPrompt, model, @@ -270,6 +347,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/db/agent-manager.test.ts b/apps/server/test/db/agent-manager.test.ts index 25cb34bd..96924744 100644 --- a/apps/server/test/db/agent-manager.test.ts +++ b/apps/server/test/db/agent-manager.test.ts @@ -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" ); diff --git a/apps/server/test/tmux-command-builder.test.ts b/apps/server/test/tmux-command-builder.test.ts index bed5c2d2..d76df314 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,201 @@ 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"; + // Reactive clauses Dispatch re-injects when they apply — dropped for + // everyone, toggle or not. + const REACTIVE_REVIEW_CLAUSES = [ + "structured REVIEW SUBMITTED prompt", + "ask the reviewer to verify it", + "zero-item approval", + ]; + + // 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 { + 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); + 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("drops the create_pr routing rule when trimmed — review-workflow carries it", () => { + const text = guidance({ agentType: "claude", trimmedGuidance: true }); + expect(text).not.toContain(CREATE_PR_RULE); + }); + + it("keeps the Autonomous Review rule short in BOTH states", () => { + for (const trimmedGuidance of [false, true]) { + const text = guidance({ + agentType: "claude", + autoReview: true, + trimmedGuidance, + }); + expect(text).toContain("Autonomous Review is enabled"); + // The proactive gates — nothing can inject these at the right moment, + // because the moment is the agent deciding it's done. + expect(text).toContain("create_pr"); + expect(text).toContain("dispatch_launch_persona"); + expect(text).toContain( + "Don't emit done until all submitted reviews are resolved" + ); + // 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); + } + } + }); + + it("still mentions create_pr under trim when autoReview supplies it", () => { + const text = guidance({ + agentType: "claude", + autoReview: true, + trimmedGuidance: true, + }); + expect(text).toContain("create_pr"); + }); + + 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("keeps every rule's subject, just shorter", () => { + const text = guidance({ + agentType: "claude", + suggestSessionRename: true, + autoReview: true, + trimmedGuidance: true, + }); + // 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", () => { + for (const agentType of ["opencode", "cursor"] as const) { + const text = guidance({ + agentType, + autoReview: true, + trimmedGuidance: true, + }); + expect(text).toContain(PLAYWRIGHT_RULE); + expect(text).toContain(CREATE_PR_RULE); + } + }); + + 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..5b22930f --- /dev/null +++ b/apps/web/src/components/app/launch-guidance-settings.tsx @@ -0,0 +1,101 @@ +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 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); + 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) return; + confirmedValue.current = data.enabled; + setEnabled(data.enabled); + setLoaded(true); + }) + .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); + 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 ( +
+
+ Launch guidance +
+

+ 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} +

+ ) : 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} /> +
+ +