diff --git a/apps/mobile/src/features/threads/DraftThreadHeader.tsx b/apps/mobile/src/features/threads/DraftThreadHeader.tsx
index 509545b58c8bb..429a50fe63d9f 100644
--- a/apps/mobile/src/features/threads/DraftThreadHeader.tsx
+++ b/apps/mobile/src/features/threads/DraftThreadHeader.tsx
@@ -71,6 +71,7 @@ export function DraftThreadHeader(props: {
canOperateTerminal: false,
canOpenFiles: canReadFiles && props.cwd !== null,
projectScripts: [],
+ canRunPrompt: false,
terminalSessions: [],
onOpenFilesInspector: openFiles,
onOpenGitInspector: requiresThread,
diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx
index 3aa5a8df67e41..2ba7ffa3c8d77 100644
--- a/apps/mobile/src/features/threads/ThreadGitControls.tsx
+++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx
@@ -107,6 +107,7 @@ type ThreadGitControlsProps = ThreadGitMenuProps & {
};
readonly canOpenTerminal: boolean;
readonly canOperateTerminal: boolean;
+ readonly canRunPrompt: boolean;
readonly canOpenFiles: boolean;
readonly projectScripts: ReadonlyArray;
readonly terminalSessions: ReadonlyArray;
@@ -281,15 +282,15 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
return {
terminal: {
accessibilityLabel: "Open terminal",
- disabled: !props.canOpenTerminal,
+ disabled: !props.canOpenTerminal && !props.canRunPrompt,
icon: { name: "terminal", type: "sfSymbol" },
identifier: "thread-right-terminal",
label: "Terminal",
menu: {
items: [
...props.projectScripts.map((script) => ({
- description: script.command,
- disabled: !props.canOperateTerminal,
+ description: script.type === "prompt" ? script.prompt : script.command,
+ disabled: script.type === "prompt" ? !props.canRunPrompt : !props.canOperateTerminal,
icon: { name: projectScriptMenuIcon(script.icon), type: "sfSymbol" as const },
label: projectScriptMenuLabel(script),
onPress: () => void props.onRunProjectScript(script),
@@ -308,6 +309,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
]
: []),
...props.terminalSessions.map((session) => ({
+ disabled: !props.canOpenTerminal,
description: [
getTerminalStatusLabel({
status: session.status,
@@ -449,7 +451,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {
{props.projectScripts.length > 0 ? (
@@ -457,9 +459,11 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {
void props.onRunProjectScript(script)}
- subtitle={script.command}
+ subtitle={script.type === "prompt" ? script.prompt : script.command}
>
{projectScriptMenuLabel(script)}
@@ -478,6 +482,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {
props.onOpenTerminal(session.terminalId)}
subtitle={[
getTerminalStatusLabel({
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index 87e57557eba36..447ed6d8b9d0d 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -71,6 +71,7 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s
import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";
import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
import { useThreadComposerState } from "../../state/use-thread-composer-state";
+import { updateComposerDraftSettings } from "../../state/use-composer-drafts";
import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships";
import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows";
import { threadEnvironment, useThreadFamily } from "../../state/threads";
@@ -692,7 +693,33 @@ function ThreadRouteContent(
});
};
+ const runPrompt = useAtomCommand(threadEnvironment.runProjectPrompt, "run prompt action");
const handleRunProjectScript = async (script: ProjectScript) => {
+ if (script.type === "prompt") {
+ if (
+ !selectedThread ||
+ !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope)
+ )
+ return;
+ const result = await runPrompt({
+ environmentId: selectedThread.environmentId,
+ input: {
+ threadId: selectedThread.id,
+ action: script,
+ creationSource: "mobile",
+ ...(composer.modelSelection ? { modelSelection: composer.modelSelection } : {}),
+ ...(composer.runtimeMode ? { runtimeMode: composer.runtimeMode } : {}),
+ ...(composer.interactionMode ? { interactionMode: composer.interactionMode } : {}),
+ },
+ });
+ if (result._tag === "Success" && script.modelSelection) {
+ updateComposerDraftSettings(
+ scopedThreadKey(selectedThread.environmentId, selectedThread.id),
+ { modelSelection: script.modelSelection },
+ );
+ }
+ return;
+ }
terminalDebugLog("project-script:press", {
scriptId: script.id,
command: script.command,
@@ -783,6 +810,7 @@ function ThreadRouteContent(
canOpenTerminal:
Boolean(selectedThreadProject?.workspaceRoot) && (canReadTerminal || canOperateTerminal),
canOperateTerminal,
+ canRunPrompt: canOperateThread,
canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot),
projectScripts: selectedThreadProject
? resolveProjectScripts(
diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts
index 1bf294641a01c..719945b4a306b 100644
--- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts
+++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts
@@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest";
import {
CommandId,
MessageId,
+ type ModelSelection,
type OrchestrationV2ProviderCapabilities,
type OrchestrationV2ThreadProjection,
ProviderInstanceId,
@@ -200,6 +201,28 @@ it.each(["/compact", " /LOGOUT "])("queues a steer sent while %s runs", (text) =
const layer = it.layer(CommandPolicy.layer);
layer("CommandPolicyV2", (it) => {
+ it.effect("starts an idle queued prompt with the thread's arbitrary model and options", () =>
+ Effect.gen(function* () {
+ const policy = yield* CommandPolicy.CommandPolicyV2;
+ const modelSelection: ModelSelection = {
+ instanceId: ProviderInstanceId.make("custom-runtime"),
+ model: "deepseek/deepseek-v4.1-flash:free",
+ options: [{ id: "reasoningEffort", value: "high" }],
+ };
+ const projection = {
+ ...dispatchProjection(),
+ thread: { id: threadId, modelSelection },
+ } as OrchestrationV2ThreadProjection;
+ const decision = yield* policy.decideMessageDispatch({
+ commandId,
+ projection,
+ requestedMode: { type: "queue_after_active" },
+ capabilities: baseCapabilities,
+ });
+ assert.deepEqual(decision, { type: "start_run", modelSelection });
+ }),
+ );
+
it.effect("falls back to portable context when Cursor cannot fork natively", () =>
Effect.gen(function* () {
const policy = yield* CommandPolicy.CommandPolicyV2;
diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts
index bd985cab203ac..9affcc947909c 100644
--- a/apps/server/src/serverSettings.test.ts
+++ b/apps/server/src/serverSettings.test.ts
@@ -1721,6 +1721,88 @@ it.layer(NodeServices.layer)("server settings", (it) => {
},
);
+ it.effect(
+ "persists prompt actions beside legacy shells and preserves unrelated settings on later saves",
+ () =>
+ Effect.gen(function* () {
+ const service = yield* ServerSettingsModule.ServerSettingsService;
+ const config = yield* ServerConfig.ServerConfig;
+ const fs = yield* FileSystem.FileSystem;
+ const projectId = ProjectId.make("prompt-project");
+ const shell: ProjectScript = {
+ id: "test",
+ name: "Test",
+ command: "npm test",
+ icon: "test",
+ runOnWorktreeCreate: true,
+ async: false,
+ runOnSettle: true,
+ };
+ const prompt: ProjectScript = {
+ id: "status",
+ name: "Status",
+ type: "prompt",
+ prompt: "Read project instructions.\nReport completed results without changes.",
+ icon: "play",
+ runOnWorktreeCreate: false,
+ };
+ yield* service.updateSettings({
+ projectSettingsOverrides: {
+ [projectId]: { defaultAutoPull: true, defaultProjectScripts: [shell, prompt] },
+ },
+ });
+ const saved = yield* decodeServerSettingsJson(
+ yield* fs.readFileString(config.settingsPath),
+ );
+ assert.deepEqual(saved.projectSettingsOverrides[projectId], {
+ defaultAutoPull: true,
+ defaultProjectScripts: [shell, prompt],
+ });
+ const reloaded = yield* Effect.gen(function* () {
+ const fresh = yield* ServerSettingsModule.ServerSettingsService;
+ return yield* fresh.getSettings;
+ }).pipe(
+ Effect.provide(
+ Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)),
+ ),
+ );
+ assert.deepEqual(
+ reloaded.projectSettingsOverrides[projectId],
+ saved.projectSettingsOverrides[projectId],
+ );
+ yield* service.updateSettings({
+ projectSettingsOverrides: {
+ [projectId]: {
+ ...saved.projectSettingsOverrides[projectId],
+ defaultProjectScripts: [shell, { ...prompt, prompt: "Inspect new results." }],
+ },
+ },
+ });
+ const edited = yield* decodeServerSettingsJson(
+ yield* fs.readFileString(config.settingsPath),
+ );
+ assert.deepEqual(edited.projectSettingsOverrides[projectId]?.defaultProjectScripts, [
+ shell,
+ { ...prompt, prompt: "Inspect new results." },
+ ]);
+ yield* service.updateSettings({
+ projectSettingsOverrides: {
+ [projectId]: {
+ ...edited.projectSettingsOverrides[projectId],
+ defaultProjectScripts: [shell],
+ },
+ },
+ });
+ const deleted = yield* decodeServerSettingsJson(
+ yield* fs.readFileString(config.settingsPath),
+ );
+ assert.deepEqual(deleted.projectSettingsOverrides[projectId], {
+ defaultAutoPull: true,
+ defaultProjectScripts: [shell],
+ });
+ }).pipe(Effect.provide(layerServerSettings())),
+ );
+
it.effect("folds legacy project overrides into projectSettingsOverrides once", () =>
Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 72ad50679d687..4ed55cc18cf19 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -1823,8 +1823,12 @@ export default function ChatView(props: ChatViewProps) {
composerRef,
terminalUiState,
activeThread,
+ modelSelection: composerSelectedModelSelection ?? activeThread?.modelSelection,
+ runtimeMode,
+ interactionMode,
activeThreadId,
activeThreadRef,
+ isServerThread,
activeProject,
activeRightPanelSurface,
runningTerminalIds,
@@ -2675,9 +2679,11 @@ export default function ChatView(props: ChatViewProps) {
},
fallback: (command) => {
const scriptId = projectScriptIdFromCommand(command);
- if (!scriptId || !activeProject || !canOperateTerminal) return undefined;
+ if (!scriptId || !activeProject) return undefined;
const script = activeProjectScripts.find((entry) => entry.id === scriptId);
- return script ? () => void runProjectScriptStable(script) : undefined;
+ if (!script || (script.type === "prompt" ? !canOperateThread : !canOperateTerminal))
+ return undefined;
+ return () => void runProjectScriptStable(script);
},
});
diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx
index 165f57fc6dd07..b932791d82459 100644
--- a/apps/web/src/components/ProjectScriptsControl.tsx
+++ b/apps/web/src/components/ProjectScriptsControl.tsx
@@ -2,6 +2,7 @@ import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";
import {
AuthSettingsWriteScope,
type EnvironmentId,
+ type ProjectId,
type ProjectScript,
type T3ProjectFileScript,
} from "@t3tools/contracts";
@@ -63,6 +64,7 @@ interface ProjectScriptsControlProps {
presentation?: "toolbar" | "menu";
onRequestMenuClose?: () => void;
environmentId: EnvironmentId;
+ projectId: ProjectId;
scripts: ReadonlyArray;
fileScripts?: ReadonlyArray;
preferredScriptId?: string | null;
@@ -80,6 +82,7 @@ export default function ProjectScriptsControl({
presentation = "toolbar",
onRequestMenuClose,
environmentId,
+ projectId,
scripts,
fileScripts = NO_FILE_SCRIPTS,
preferredScriptId = null,
@@ -111,7 +114,7 @@ export default function ProjectScriptsControl({
(fileScript) =>
!scripts.some(
(script) =>
- script.command === fileScript.command ||
+ (script.type !== "prompt" && script.command === fileScript.command) ||
script.name.toLowerCase() === fileScript.name.toLowerCase(),
),
);
@@ -450,6 +453,7 @@ export default function ProjectScriptsControl({
;
readonly activeThread: EnvironmentThreadShell | undefined;
+ readonly modelSelection: EnvironmentThreadShell["modelSelection"] | undefined;
+ readonly runtimeMode: EnvironmentThreadShell["runtimeMode"] | undefined;
+ readonly interactionMode: EnvironmentThreadShell["interactionMode"] | undefined;
readonly activeThreadId: ThreadId | null;
readonly activeThreadRef: ScopedThreadRef | null;
+ readonly isServerThread: boolean;
readonly activeProject: EnvironmentProject | null;
readonly activeRightPanelSurface: RightPanelSurface | null;
readonly runningTerminalIds: ReadonlyArray;
@@ -85,8 +92,12 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) {
composerRef,
terminalUiState,
activeThread,
+ modelSelection,
+ runtimeMode,
+ interactionMode,
activeThreadId,
activeThreadRef,
+ isServerThread,
activeProject,
activeRightPanelSurface,
runningTerminalIds,
@@ -98,6 +109,8 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) {
setTerminalUiLaunchContext,
setThreadError,
} = input;
+ const runPrompt = useAtomCommand(threadEnvironment.runProjectPrompt, { reportFailure: false });
+ const setComposerModelSelection = useComposerDraftStore((store) => store.setModelSelection);
const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false });
const hasTerminalWriteAccess = () =>
@@ -205,13 +218,58 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) {
shellHistory?: "preserve" | "suppress";
},
) => {
- if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return;
+ if (!activeThreadId || !activeProject || !activeThread) return;
+ if (script.type !== "prompt" && !hasTerminalWriteAccess()) return;
if (options?.rememberAsLastInvoked !== false) {
setLastInvokedScriptByProjectId((current) => {
if (current[activeProject.id] === script.id) return current;
return { ...current, [activeProject.id]: script.id };
});
}
+ if (script.type === "prompt") {
+ if (!isServerThread) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "warning",
+ title: "Start this thread first",
+ description: "Send a message before running a prompt action.",
+ }),
+ );
+ return;
+ }
+ if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
+ setThreadError(activeThreadId, "This connection cannot run prompt actions.");
+ return;
+ }
+ const result = await runPrompt({
+ environmentId,
+ input: {
+ threadId: activeThreadId,
+ action: script,
+ ...(modelSelection ? { modelSelection } : {}),
+ ...(runtimeMode ? { runtimeMode } : {}),
+ ...(interactionMode ? { interactionMode } : {}),
+ },
+ });
+ if (result._tag === "Failure") {
+ if (!isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ setThreadError(
+ activeThreadId,
+ error instanceof Error
+ ? error.message
+ : `Could not run prompt action "${script.name}". Check that the thread's model and provider are available.`,
+ );
+ }
+ return;
+ }
+ if (script.modelSelection && activeThreadRef) {
+ setComposerModelSelection(activeThreadRef, script.modelSelection, {
+ replaceOptions: true,
+ });
+ }
+ return;
+ }
const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot;
const reusableTerminalId =
options?.preferNewTerminal ||
diff --git a/apps/web/src/components/projectScriptEditor.browser.test.tsx b/apps/web/src/components/projectScriptEditor.browser.test.tsx
index a9ea6f03ad185..3720e9f87030e 100644
--- a/apps/web/src/components/projectScriptEditor.browser.test.tsx
+++ b/apps/web/src/components/projectScriptEditor.browser.test.tsx
@@ -1,15 +1,48 @@
import { act, render, type Screen } from "../../test/render";
-import { EnvironmentId } from "@t3tools/contracts";
-import { AsyncResult } from "effect/reactivity";
+import {
+ DEFAULT_UNIFIED_SETTINGS,
+ EnvironmentId,
+ ProjectId,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ type ServerProvider,
+ type UnifiedSettings,
+} from "@t3tools/contracts";
+import { RegistryContext } from "@effect/atom-react";
+import { AsyncResult, Atom } from "effect/reactivity";
+import { page, userEvent } from "vite-plus/test/browser";
import { StrictMode } from "react";
import { describe, expect, it, vi } from "vite-plus/test";
+const state = vi.hoisted(() => ({
+ providers: null as unknown as Atom.Writable>,
+ settings: null as unknown as UnifiedSettings,
+}));
+
vi.mock(import("~/state/session"), async (original) => ({
...(await original()),
readEnvironmentScope: () => true,
useEnvironmentScope: () => true,
}));
+vi.mock(import("~/state/server"), async (original) => {
+ const actual = await original();
+ const { Atom } = await import("effect/reactivity");
+ const providers = Atom.make>([]).pipe(Atom.keepAlive);
+ state.providers = providers;
+ return {
+ ...actual,
+ serverEnvironment: {
+ ...actual.serverEnvironment,
+ providersValueAtom: (() => providers) as never,
+ },
+ };
+});
+vi.mock(import("~/hooks/useSettings"), async (original) => ({
+ ...(await original()),
+ useEnvironmentSettings: (() => state.settings) as never,
+}));
+import { appAtomRegistry } from "~/rpc/atomRegistry";
import {
EMPTY_PROJECT_SCRIPT_INPUT,
ProjectScriptEditorDialog,
@@ -29,18 +62,25 @@ function request(name: string, error?: string): ProjectScriptEditorRequest {
};
}
-const open = (screen: Screen, nextRequest: ProjectScriptEditorRequest) =>
+const open = (
+ screen: Screen,
+ nextRequest: ProjectScriptEditorRequest,
+ projectId: ProjectId | null = null,
+) =>
screen.rerender(
-
-
- ,
+
+
+
+
+ ,
);
const submit = () => document.querySelector("form")!.requestSubmit();
@@ -56,15 +96,17 @@ function deferredSave() {
return { promise, resolve, reject };
}
-function setup() {
+async function setup() {
onSubmit.mockReset();
onClose.mockReset();
onDelete.mockReset();
+ state.settings = DEFAULT_UNIFIED_SETTINGS;
+ await act(async () => appAtomRegistry.set(state.providers, []));
}
describe("project action editor save lifecycle", () => {
it("does not close a replacement request or release its in-flight save", async () => {
- setup();
+ await setup();
await using screen = await render(null);
const first = deferredSave();
const second = deferredSave();
@@ -86,3 +128,241 @@ describe("project action editor save lifecycle", () => {
expect(onSubmit.mock.calls.map(([scriptId]) => scriptId)).toEqual(["build", "test"]);
});
});
+
+it("switches from shell to prompt, records a shortcut, and saves the prompt editor", async () => {
+ await setup();
+ onSubmit.mockResolvedValue(AsyncResult.success(undefined));
+ await using screen = await render(null);
+ await open(screen, {
+ scriptId: null,
+ initial: { ...EMPTY_PROJECT_SCRIPT_INPUT, name: "Research status" },
+ });
+ await expect.element(page.getByLabelText("Command", { exact: true })).toBeVisible();
+ await userEvent.click(page.getByRole("combobox", { name: "Action type" }));
+ await userEvent.click(page.getByRole("option", { name: "Prompt", exact: true }));
+ await expect.element(page.getByLabelText("Command", { exact: true })).not.toBeInTheDocument();
+ await expect
+ .element(page.getByText("Run automatically on worktree creation"))
+ .not.toBeInTheDocument();
+ await userEvent.type(
+ page.getByLabelText("Prompt", { exact: true }),
+ "Read instructions. Report results without edits.",
+ );
+ await userEvent.click(page.getByLabelText("Keybinding", { exact: true }));
+ await userEvent.keyboard("{Control>}{Shift>}p{/Shift}{/Control}");
+ await expect
+ .element(page.getByLabelText("Keybinding", { exact: true }))
+ .toHaveValue("ctrl+shift+p");
+ await act(async () => submit());
+ await vi.waitFor(() =>
+ expect(onSubmit).toHaveBeenCalledWith(
+ null,
+ expect.objectContaining({
+ type: "prompt",
+ prompt: "Read instructions. Report results without edits.",
+ name: "Research status",
+ keybinding: "ctrl+shift+p",
+ }),
+ ),
+ );
+});
+
+it("shows a provider that only the project enables", async () => {
+ await setup();
+ const projectId = ProjectId.make("project-a");
+ const instanceId = ProviderInstanceId.make("codex_work");
+ const provider: ServerProvider = {
+ instanceId,
+ driver: ProviderDriverKind.make("codex"),
+ displayName: "Codex Work",
+ enabled: false,
+ installed: true,
+ version: "1.0.0",
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-10-23T00:00:00.000Z",
+ models: [{ slug: "gpt-5", name: "GPT-5", isCustom: false, capabilities: null }],
+ skills: [],
+ slashCommands: [],
+ };
+ state.settings = {
+ ...DEFAULT_UNIFIED_SETTINGS,
+ providerInstances: {
+ [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false },
+ },
+ projectSettingsOverrides: {
+ [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
+ },
+ };
+ await act(async () => appAtomRegistry.set(state.providers, [provider]));
+ await using screen = await render(null);
+ await open(
+ screen,
+ {
+ scriptId: "status",
+ initial: {
+ ...EMPTY_PROJECT_SCRIPT_INPUT,
+ type: "prompt",
+ prompt: "Report status.",
+ modelSelection: { instanceId, model: "gpt-5" },
+ },
+ },
+ projectId,
+ );
+
+ await userEvent.click(page.getByRole("button", { name: /GPT-5/ }));
+ await expect.element(page.getByRole("button", { name: "Codex Work", exact: true })).toBeVisible();
+});
+
+it("falls back to a project-enabled model when the environment default is disabled", async () => {
+ await setup();
+ onSubmit.mockResolvedValue(AsyncResult.success(undefined));
+ const projectId = ProjectId.make("project-a");
+ const codexInstanceId = ProviderInstanceId.make("codex");
+ const claudeInstanceId = ProviderInstanceId.make("claudeAgent");
+ const providers: ReadonlyArray = [
+ {
+ instanceId: codexInstanceId,
+ driver: ProviderDriverKind.make("codex"),
+ displayName: "Codex",
+ enabled: true,
+ installed: true,
+ version: "1.0.0",
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-10-23T00:00:00.000Z",
+ models: [
+ { slug: "gpt-5", name: "GPT-5", isCustom: false, isDefault: true, capabilities: null },
+ ],
+ skills: [],
+ slashCommands: [],
+ },
+ {
+ instanceId: claudeInstanceId,
+ driver: ProviderDriverKind.make("claudeAgent"),
+ displayName: "Claude",
+ enabled: true,
+ installed: true,
+ version: "1.0.0",
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-10-23T00:00:00.000Z",
+ models: [
+ {
+ slug: "claude-opus-4-8",
+ name: "Claude Opus 4.8",
+ isCustom: false,
+ isDefault: true,
+ capabilities: null,
+ },
+ ],
+ skills: [],
+ slashCommands: [],
+ },
+ ];
+ state.settings = {
+ ...DEFAULT_UNIFIED_SETTINGS,
+ projectSettingsOverrides: {
+ [projectId]: { providerInstances: { [codexInstanceId]: { enabled: false } } },
+ },
+ };
+ await act(async () => appAtomRegistry.set(state.providers, providers));
+ await using screen = await render(null);
+ await open(
+ screen,
+ {
+ scriptId: "review",
+ initial: {
+ ...EMPTY_PROJECT_SCRIPT_INPUT,
+ name: "Review",
+ type: "prompt",
+ prompt: "Review the code.",
+ },
+ },
+ projectId,
+ );
+
+ await userEvent.click(page.getByText("Use the thread's current model"));
+ await expect.element(page.getByRole("button", { name: /Claude Opus 4.8/ })).toBeVisible();
+
+ await act(async () => submit());
+ await vi.waitFor(() =>
+ expect(onSubmit).toHaveBeenCalledWith(
+ "review",
+ expect.objectContaining({
+ modelSelection: { instanceId: claudeInstanceId, model: "claude-opus-4-8" },
+ }),
+ ),
+ );
+});
+
+it("applies and displays an Ultrathink choice in the prompt", async () => {
+ await setup();
+ onSubmit.mockResolvedValue(AsyncResult.success(undefined));
+ const instanceId = ProviderInstanceId.make("claudeAgent");
+ const model = "claude-sonnet-4-5";
+ const provider: ServerProvider = {
+ instanceId,
+ driver: ProviderDriverKind.make("claudeAgent"),
+ displayName: "Claude",
+ enabled: true,
+ installed: true,
+ version: "1.0.0",
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-10-23T00:00:00.000Z",
+ models: [
+ {
+ slug: model,
+ name: "Claude Sonnet 4.5",
+ isCustom: false,
+ capabilities: {
+ optionDescriptors: [
+ {
+ id: "effort",
+ label: "Effort",
+ type: "select",
+ currentValue: "high",
+ promptInjectedValues: ["ultrathink"],
+ options: [
+ { id: "low", label: "Low" },
+ { id: "medium", label: "Medium" },
+ { id: "high", label: "High", isDefault: true },
+ { id: "ultrathink", label: "Ultrathink" },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ skills: [],
+ slashCommands: [],
+ };
+ await act(async () => appAtomRegistry.set(state.providers, [provider]));
+ await using screen = await render(null);
+ await open(screen, {
+ scriptId: "review",
+ initial: {
+ ...EMPTY_PROJECT_SCRIPT_INPUT,
+ name: "Review",
+ type: "prompt",
+ prompt: "Review the code.",
+ modelSelection: { instanceId, model, options: [{ id: "effort", value: "high" }] },
+ },
+ });
+
+ await userEvent.click(page.getByRole("button", { name: "High", exact: true }));
+ await userEvent.click(page.getByRole("menuitemradio", { name: /Ultrathink/ }));
+ await expect
+ .element(page.getByLabelText("Prompt", { exact: true }))
+ .toHaveValue("Ultrathink:\nReview the code.");
+ await expect.element(page.getByRole("button", { name: "Ultrathink", exact: true })).toBeVisible();
+
+ await act(async () => submit());
+ await vi.waitFor(() =>
+ expect(onSubmit).toHaveBeenCalledWith(
+ "review",
+ expect.objectContaining({ prompt: "Ultrathink:\nReview the code." }),
+ ),
+ );
+});
diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx
index 7f44194f98354..8bcec0829d5bc 100644
--- a/apps/web/src/components/projectScriptEditor.tsx
+++ b/apps/web/src/components/projectScriptEditor.tsx
@@ -2,6 +2,8 @@ import {
AuthOrchestrationOperateScope,
AuthSettingsWriteScope,
type EnvironmentId,
+ type ModelSelection,
+ type ProjectId,
type ProjectScript,
type ProjectScriptIcon,
type ResolvedKeybindingsConfig,
@@ -11,6 +13,8 @@ import {
squashAtomCommandFailure,
type AtomCommandResult,
} from "@t3tools/client-runtime/state/runtime";
+import { createModelSelection } from "@t3tools/shared/model";
+import { useAtomValue } from "@effect/atom-react";
import {
BugIcon,
FlaskConicalIcon,
@@ -27,7 +31,18 @@ import {
} from "~/lib/projectScriptKeybindings";
import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic";
import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts";
+import {
+ applyProviderInstanceSettings,
+ deriveProviderInstanceEntries,
+ resolveDefaultProviderModelSelectionFromEntries,
+ sortProviderInstanceEntries,
+} from "~/providerInstances";
+import { getCustomModelOptionsByInstance } from "~/modelSelection";
+import { useEnvironmentSettings } from "~/hooks/useSettings";
+import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "~/state/server";
import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
+import { ProviderModelPicker } from "./chat/ProviderModelPicker";
+import { readPromptUltrathink, TraitsPicker } from "./chat/TraitsPicker";
import { useComposerMenuState } from "./chat/useComposerMenuState";
import {
AlertDialog,
@@ -51,6 +66,7 @@ import {
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
+import { Select, SelectTrigger, SelectValue, SelectPopup, SelectItem } from "./ui/select";
import { Switch } from "./ui/switch";
import { Textarea } from "./ui/textarea";
@@ -80,6 +96,9 @@ export function ScriptIcon({
export interface NewProjectScriptInput {
name: string;
+ type?: "shell" | "prompt";
+ prompt?: string;
+ modelSelection?: ModelSelection | null;
command: string;
icon: ProjectScriptIcon;
runOnWorktreeCreate: boolean;
@@ -119,7 +138,10 @@ export function editorRequestForScript(
scriptId: script.id,
initial: {
name: script.name,
- command: script.command,
+ type: script.type ?? "shell",
+ prompt: script.type === "prompt" ? script.prompt : "",
+ modelSelection: script.type === "prompt" ? (script.modelSelection ?? null) : null,
+ command: script.type === "prompt" ? "" : script.command,
icon: script.icon,
runOnWorktreeCreate: script.runOnWorktreeCreate,
waitForSetup: script.runOnWorktreeCreate && script.async === false,
@@ -134,6 +156,7 @@ export function editorRequestForScript(
export function ProjectScriptEditorDialog({
environmentId,
editScope = AuthOrchestrationOperateScope,
+ projectId,
request,
scripts,
onSubmit,
@@ -142,6 +165,7 @@ export function ProjectScriptEditorDialog({
}: {
environmentId: EnvironmentId;
editScope?: typeof AuthOrchestrationOperateScope | typeof AuthSettingsWriteScope;
+ projectId: ProjectId | null;
request: ProjectScriptEditorRequest | null;
scripts: ReadonlyArray;
onSubmit: (
@@ -193,6 +217,7 @@ export function ProjectScriptEditorDialog({
key={shown.key}
environmentId={environmentId}
editScope={editScope}
+ projectId={projectId}
request={shown.request}
scripts={scripts}
validationError={validationError}
@@ -242,6 +267,7 @@ export function ProjectScriptEditorDialog({
function ProjectScriptEditorForm({
environmentId,
editScope,
+ projectId,
request,
scripts,
validationError,
@@ -252,6 +278,7 @@ function ProjectScriptEditorForm({
}: {
environmentId: EnvironmentId;
editScope: typeof AuthOrchestrationOperateScope | typeof AuthSettingsWriteScope;
+ projectId: ProjectId | null;
request: ProjectScriptEditorRequest;
scripts: ReadonlyArray;
validationError: string | null;
@@ -267,6 +294,8 @@ function ProjectScriptEditorForm({
const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope);
const formId = React.useId();
const [name, setName] = useState(request.initial.name);
+ const [actionType, setActionType] = useState<"shell" | "prompt">(request.initial.type ?? "shell");
+ const [prompt, setPrompt] = useState(request.initial.prompt ?? "");
const [command, setCommand] = useState(request.initial.command);
const [icon, setIcon] = useState(request.initial.icon);
const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditActions);
@@ -278,7 +307,35 @@ function ProjectScriptEditorForm({
const [keybinding, setKeybinding] = useState(request.initial.keybinding ?? "");
const [previewUrl, setPreviewUrl] = useState(request.initial.previewUrl ?? "");
const [autoOpenPreview, setAutoOpenPreview] = useState(request.initial.autoOpenPreview);
+ const [modelSelection, setModelSelection] = useState(
+ request.initial.modelSelection ?? null,
+ );
const [isSaving, setIsSaving] = useState(false);
+ const settings = useEnvironmentSettings(environmentId);
+ const providers =
+ useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
+ const instanceEntries = sortProviderInstanceEntries(
+ applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings, projectId),
+ );
+ const fallbackModelSelection =
+ resolveDefaultProviderModelSelectionFromEntries(
+ instanceEntries,
+ settings.defaultModelSelection,
+ ) ??
+ instanceEntries.reduce((selection, entry) => {
+ if (selection || !entry.enabled || !entry.models[0]) return selection;
+ return createModelSelection(entry.instanceId, entry.models[0].slug);
+ }, null);
+ const effectiveModelSelection = modelSelection ?? fallbackModelSelection;
+ const activeEntry =
+ instanceEntries.find((entry) => entry.instanceId === effectiveModelSelection?.instanceId) ??
+ null;
+ const modelOptionsByInstance = getCustomModelOptionsByInstance(
+ settings,
+ providers,
+ effectiveModelSelection?.instanceId,
+ effectiveModelSelection?.model,
+ );
const pendingSubmissionRef = useRef
-
-
-
-
-
setPreviewUrl(event.target.value)}
- />
-
- Open this URL in the in-app preview when this action runs.
-
+
+
-
-
-
-
+ {actionType === "prompt" ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
setPreviewUrl(event.target.value)}
+ />
+
+ Open this URL in the in-app preview when this action runs.
+
+
+
+
+
+
+ >
+ )}
{validationError && {validationError}
}
diff --git a/apps/web/src/components/settings/ProjectActionsList.tsx b/apps/web/src/components/settings/ProjectActionsList.tsx
index cc19de17746be..d58c8df852d99 100644
--- a/apps/web/src/components/settings/ProjectActionsList.tsx
+++ b/apps/web/src/components/settings/ProjectActionsList.tsx
@@ -33,6 +33,11 @@ export function ProjectActionsList({
{script.name}
+ {script.type === "prompt" ? (
+
+ prompt
+
+ ) : null}
{script.runOnWorktreeCreate ? (
setup
@@ -50,7 +55,13 @@ export function ProjectActionsList({
) : null}
}
- description={{script.command}}
+ description={
+ script.type === "prompt" ? (
+ {script.prompt}
+ ) : (
+ {script.command}
+ )
+ }
control={
<>
{shortcutLabel ? (
diff --git a/apps/web/src/components/settings/ProjectActionsSettings.tsx b/apps/web/src/components/settings/ProjectActionsSettings.tsx
index fc55d826e2364..a19bee22d63e0 100644
--- a/apps/web/src/components/settings/ProjectActionsSettings.tsx
+++ b/apps/web/src/components/settings/ProjectActionsSettings.tsx
@@ -87,7 +87,7 @@ export function ProjectActionsSettings() {
(fileScript) =>
!scripts.some(
(script) =>
- script.command === fileScript.command ||
+ (script.type !== "prompt" && script.command === fileScript.command) ||
script.name.toLowerCase() === fileScript.name.toLowerCase(),
),
);
@@ -202,6 +202,7 @@ export function ProjectActionsSettings() {
{
).toBe(holder);
});
});
+
+it("creates and edits prompt actions without shell settings or claiming lifecycle roles", () => {
+ const input = {
+ type: "prompt",
+ prompt: "Read project instructions and report status.",
+ command: "echo old shell",
+ name: "Status",
+ icon: "debug",
+ runOnWorktreeCreate: true,
+ waitForSetup: true,
+ runOnSettle: true,
+ previewUrl: "http://localhost:3000",
+ autoOpenPreview: true,
+ } as const;
+ const action = buildProjectScript("status", input);
+ expect(action).toEqual({
+ id: "status",
+ name: "Status",
+ type: "prompt",
+ prompt: input.prompt,
+ icon: "debug",
+ runOnWorktreeCreate: false,
+ });
+ expect(setupProjectScript([action])).toBeNull();
+ expect(settleProjectScript([action])).toBeNull();
+ expect(
+ buildProjectScript(action.id, { ...input, name: "Updated", prompt: "Read new outputs." }),
+ ).toMatchObject({ id: "status", name: "Updated", prompt: "Read new outputs." });
+ const modelSelection = {
+ instanceId: ProviderInstanceId.make("custom-provider"),
+ model: "deepseek/deepseek-v4.1-flash:free",
+ options: [{ id: "reasoningEffort", value: "max" as const }],
+ };
+ expect(buildProjectScript("status", { ...input, modelSelection })).toMatchObject({
+ modelSelection,
+ });
+ const shell = buildProjectScript("shell", { ...input, type: "shell" });
+ expect(shell).toMatchObject({
+ command: "echo old shell",
+ runOnWorktreeCreate: true,
+ async: false,
+ runOnSettle: true,
+ previewUrl: input.previewUrl,
+ autoOpenPreview: true,
+ });
+ expect(shell).not.toHaveProperty("prompt");
+ expect(releaseClaimedRoles(shell, input)).toBe(shell);
+});
diff --git a/apps/web/src/projectScripts.ts b/apps/web/src/projectScripts.ts
index dffdb0337d5c3..22ab6ed16bb0d 100644
--- a/apps/web/src/projectScripts.ts
+++ b/apps/web/src/projectScripts.ts
@@ -2,6 +2,7 @@ import {
MAX_SCRIPT_ID_LENGTH,
SCRIPT_RUN_COMMAND_PATTERN,
type KeybindingCommand,
+ type ModelSelection,
type ProjectScript,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
@@ -9,7 +10,10 @@ const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN);
export interface ProjectScriptInput {
readonly name: ProjectScript["name"];
- readonly command: ProjectScript["command"];
+ readonly type?: "shell" | "prompt";
+ readonly prompt?: string;
+ readonly modelSelection?: ModelSelection | null;
+ readonly command: string;
readonly icon: ProjectScript["icon"];
readonly runOnWorktreeCreate: ProjectScript["runOnWorktreeCreate"];
readonly waitForSetup: boolean;
@@ -19,6 +23,17 @@ export interface ProjectScriptInput {
}
export function buildProjectScript(id: string, input: ProjectScriptInput): ProjectScript {
+ if (input.type === "prompt") {
+ return {
+ id,
+ name: input.name,
+ type: "prompt",
+ prompt: input.prompt ?? "",
+ ...(input.modelSelection ? { modelSelection: input.modelSelection } : {}),
+ icon: input.icon,
+ runOnWorktreeCreate: false,
+ };
+ }
return {
id,
name: input.name,
@@ -40,6 +55,7 @@ export function releaseClaimedRoles(
script: ProjectScript,
saved: ProjectScriptInput,
): ProjectScript {
+ if (script.type === "prompt" || saved.type === "prompt") return script;
const releaseSetup = saved.runOnWorktreeCreate && script.runOnWorktreeCreate;
const releaseSettle = saved.runOnSettle && script.runOnSettle === true;
if (!releaseSetup && !releaseSettle) return script;
diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts
index 60c7de4eccac5..6ae8f47b66577 100644
--- a/apps/web/src/providerInstances.ts
+++ b/apps/web/src/providerInstances.ts
@@ -188,12 +188,7 @@ function getProviderInstanceEntry(
return deriveProviderInstanceEntries(providers).find((entry) => entry.instanceId === instanceId);
}
-export function getDefaultProviderInstanceModel(
- providers: ReadonlyArray,
- instanceId: ProviderInstanceId,
-): string | undefined {
- const entry = getProviderInstanceEntry(providers, instanceId);
- if (!entry) return undefined;
+function getDefaultProviderInstanceEntryModel(entry: ProviderInstanceEntry): string | undefined {
return (
entry.models.find((model) => model.isDefault && !model.isCustom)?.slug ??
entry.models.find((model) => !model.isCustom)?.slug ??
@@ -202,6 +197,14 @@ export function getDefaultProviderInstanceModel(
);
}
+export function getDefaultProviderInstanceModel(
+ providers: ReadonlyArray,
+ instanceId: ProviderInstanceId,
+): string | undefined {
+ const entry = getProviderInstanceEntry(providers, instanceId);
+ return entry ? getDefaultProviderInstanceEntryModel(entry) : undefined;
+}
+
const isSelectableProviderInstanceEntry = (entry: ProviderInstanceEntry): boolean =>
entry.enabled && entry.isAvailable;
@@ -229,13 +232,23 @@ export function resolveSelectableProviderInstance(
return resolveSelectableProviderInstanceEntry(entries, instanceId)?.instanceId;
}
+export function resolveDefaultProviderModelSelectionFromEntries(
+ entries: ReadonlyArray,
+ selection: ModelSelection | null | undefined,
+): ModelSelection | null {
+ const entry = resolveSelectableProviderInstanceEntry(entries, selection?.instanceId);
+ if (!entry) return null;
+ if (selection?.instanceId === entry.instanceId) return selection;
+ const model = getDefaultProviderInstanceEntryModel(entry);
+ return model ? { instanceId: entry.instanceId, model } : null;
+}
+
export function resolveDefaultProviderModelSelection(
providers: ReadonlyArray,
selection: ModelSelection | null | undefined,
): ModelSelection | null {
- const instanceId = resolveSelectableProviderInstance(providers, selection?.instanceId);
- if (instanceId === undefined) return null;
- if (selection?.instanceId === instanceId) return selection;
- const model = getDefaultProviderInstanceModel(providers, instanceId);
- return model ? { instanceId, model } : null;
+ return resolveDefaultProviderModelSelectionFromEntries(
+ deriveProviderInstanceEntries(providers),
+ selection,
+ );
}
diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md
index 75b7a85504793..c30f87ad49a8b 100644
--- a/docs/user/keybindings.md
+++ b/docs/user/keybindings.md
@@ -98,7 +98,7 @@ Invalid rules are ignored; if the file cannot be parsed, T3 Code uses defaults.
Each rule requires a `key` shortcut and a `command` ID. An optional `when`
expression restricts when it runs.
-Project scripts use `script.{id}.run`, such as `script.test.run`.
+Project actions, including shell scripts and prompts, use `script.{id}.run`, such as `script.test.run`.
## Key syntax
diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md
index 905b323320a55..5d99f5b472ea0 100644
--- a/docs/user/project-settings.md
+++ b/docs/user/project-settings.md
@@ -156,6 +156,14 @@ checkouts and removal. Actions belong to a project: editing them creates the pro
on each selected environment, and reset returns to the environment's shared list. A project's
`t3.json` actions can be imported there.
+Choose **Shell script** to run a terminal command, or **Prompt** to save a task for the agent.
+Run a prompt from the thread's action menu or its configured keyboard shortcut. By default it
+uses that thread's model, workspace, and permissions, and queues behind any active turn. To give
+an action its own model and reasoning level, turn off **Use the thread's current model** and pick
+one; the thread switches to that selection before the prompt runs. Start the thread with a
+message before running a prompt action from a new thread. Automatic setup and settle hooks apply
+only to shell scripts.
+
Settings a repository can also declare in `t3.json`, such as the workspace for new threads,
resolve in one order: a project override, then the environment setting, then `t3.json`, then the
built-in default. Leave a setting on **Inherit** to let the next tier decide.
diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts
index bcfae19403483..2bddb04d5c2ea 100644
--- a/packages/client-runtime/src/operations/commands.test.ts
+++ b/packages/client-runtime/src/operations/commands.test.ts
@@ -41,6 +41,7 @@ import {
revertThreadCheckpoint,
resumeThreadQueue,
startThreadTurn,
+ runProjectPrompt,
} from "./commands.ts";
const layerTestCrypto = Layer.succeed(
@@ -693,3 +694,180 @@ describe("V2 environment commands", () => {
}).pipe(Effect.provide(layerTestCrypto)),
);
});
+
+it.effect(
+ "runs a saved project prompt through normal dispatch in the invoking thread and worktree",
+ () =>
+ Effect.gen(function* () {
+ const commands: OrchestrationV2Command[] = [];
+ const launches: OrchestrationV2ThreadLaunchInput[] = [];
+ const projection: OrchestrationV2ThreadProjection = {
+ ...v2Projection,
+ thread: {
+ ...v2Projection.thread,
+ worktreePath: "/workspace/research-worktree",
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("custom-provider"),
+ model: "deepseek/deepseek-v4.1-flash:free",
+ options: [{ id: "reasoningEffort", value: "high" }],
+ },
+ runtimeMode: "full-access",
+ },
+ };
+ const supervisor = yield* makeSupervisor({ commands, projects: [], launches, projection });
+ const action = {
+ id: "status",
+ name: "Status",
+ type: "prompt",
+ prompt: "Read project instructions.\nInspect results without edits.",
+ icon: "play",
+ runOnWorktreeCreate: false,
+ } as const;
+ const selectedModel = {
+ instanceId: ProviderInstanceId.make("custom-provider"),
+ model: "deepseek/deepseek-v4.1-flash:free",
+ options: [{ id: "reasoningEffort", value: "max" }],
+ };
+ yield* runProjectPrompt({
+ commandId: CommandId.make("project-prompt"),
+ threadId: projection.thread.id,
+ action,
+ modelSelection: selectedModel,
+ runtimeMode: "approval-required",
+ interactionMode: "plan",
+ }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
+ expect(launches).toEqual([]);
+ expect(commands).toHaveLength(4);
+ expect(commands.map((command) => command.commandId)).toEqual([
+ "project-prompt:model-selection",
+ "project-prompt:runtime-mode",
+ "project-prompt:interaction-mode",
+ "project-prompt",
+ ]);
+ expect(commands[0]).toMatchObject({
+ type: "thread.model-selection.set",
+ modelSelection: selectedModel,
+ });
+ expect(commands[1]).toMatchObject({
+ type: "thread.runtime-mode.set",
+ runtimeMode: "approval-required",
+ });
+ expect(commands[2]).toMatchObject({
+ type: "thread.interaction-mode.set",
+ interactionMode: "plan",
+ });
+ expect(commands[3]).toMatchObject({
+ type: "message.dispatch",
+ threadId: projection.thread.id,
+ text: action.prompt,
+ attachments: [],
+ createdBy: "user",
+ dispatchMode: { type: "queue_after_active" },
+ });
+ expect(commands[3]).not.toHaveProperty("runtimeMode");
+ expect(commands[3]).not.toHaveProperty("worktreePath");
+ expect(projection.thread.worktreePath).toBe("/workspace/research-worktree");
+ }).pipe(Effect.provide(layerTestCrypto)),
+);
+
+it.effect("prefers a saved prompt action's model over the composer and thread selections", () =>
+ Effect.gen(function* () {
+ const commands: OrchestrationV2Command[] = [];
+ const currentModel = {
+ instanceId: ProviderInstanceId.make("custom-provider"),
+ model: "deepseek/deepseek-v4.1-flash:free",
+ options: [{ id: "reasoningEffort", value: "high" }],
+ };
+ const actionModel = {
+ instanceId: ProviderInstanceId.make("claudeAgent"),
+ model: "claude-opus-4-1",
+ options: [{ id: "effort", value: "max" }],
+ };
+ const composerModel = {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5.1-codex",
+ };
+ const projection: OrchestrationV2ThreadProjection = {
+ ...v2Projection,
+ thread: { ...v2Projection.thread, modelSelection: currentModel },
+ };
+ const supervisor = yield* makeSupervisor({ commands, projects: [], projection });
+ yield* runProjectPrompt({
+ threadId: projection.thread.id,
+ action: {
+ id: "status",
+ name: "Status",
+ type: "prompt",
+ prompt: "Inspect results.",
+ modelSelection: actionModel,
+ icon: "play",
+ runOnWorktreeCreate: false,
+ },
+ modelSelection: composerModel,
+ }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
+ expect(commands[0]).toMatchObject({
+ type: "thread.model-selection.set",
+ modelSelection: actionModel,
+ });
+ expect(commands[1]).toMatchObject({
+ type: "message.dispatch",
+ text: "Inspect results.",
+ modelSelection: actionModel,
+ });
+ }).pipe(Effect.provide(layerTestCrypto)),
+);
+
+it.effect("reports an unavailable environment when running a saved prompt", () =>
+ Effect.gen(function* () {
+ const commands: OrchestrationV2Command[] = [];
+ const supervisor = yield* makeSupervisor({ commands, projects: [] });
+ yield* SubscriptionRef.set(supervisor.session, Option.none());
+ const result = yield* runProjectPrompt({
+ threadId: v2ThreadId,
+ action: {
+ id: "status",
+ name: "Status",
+ type: "prompt",
+ prompt: "Inspect results.",
+ icon: "play",
+ runOnWorktreeCreate: false,
+ },
+ }).pipe(
+ Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
+ Effect.result,
+ );
+ expect(result._tag).toBe("Failure");
+ expect(commands).toEqual([]);
+ }).pipe(Effect.provide(layerTestCrypto)),
+);
+
+it.effect("keeps persisted thread settings when a prompt has no composer overrides", () =>
+ Effect.gen(function* () {
+ const commands: OrchestrationV2Command[] = [];
+ const projection: OrchestrationV2ThreadProjection = {
+ ...v2Projection,
+ thread: {
+ ...v2Projection.thread,
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5.1-codex",
+ options: [{ id: "reasoningEffort", value: "high" }],
+ },
+ },
+ };
+ const supervisor = yield* makeSupervisor({ commands, projects: [], projection });
+ yield* runProjectPrompt({
+ threadId: projection.thread.id,
+ action: {
+ id: "status",
+ name: "Status",
+ type: "prompt",
+ prompt: "Inspect results.",
+ icon: "play",
+ runOnWorktreeCreate: false,
+ },
+ }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
+ expect(commands).toHaveLength(1);
+ expect(commands[0]).toMatchObject({ type: "message.dispatch", text: "Inspect results." });
+ }).pipe(Effect.provide(layerTestCrypto)),
+);
diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts
index 1f96499470712..533bb1e1a54f8 100644
--- a/packages/client-runtime/src/operations/commands.ts
+++ b/packages/client-runtime/src/operations/commands.ts
@@ -28,7 +28,7 @@ import {
type UploadChatAttachment,
type GuestMessageId,
} from "@t3tools/contracts";
-import { modelSelectionCommandType } from "@t3tools/shared/model";
+import { modelSelectionCommandType, modelSelectionsEqual } from "@t3tools/shared/model";
import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
import * as Crypto from "effect/Crypto";
@@ -824,6 +824,62 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")(
});
});
+export interface RunProjectPromptInput extends ThreadCommandInput {
+ readonly action: Extract;
+ readonly modelSelection?: ModelSelection;
+ readonly runtimeMode?: RuntimeMode;
+ readonly interactionMode?: ProviderInteractionMode;
+}
+
+export const runProjectPrompt = Effect.fn("EnvironmentCommands.runProjectPrompt")(function* (
+ input: RunProjectPromptInput,
+) {
+ const projection = yield* getProjection(input.threadId);
+ const current = projection.thread;
+ const modelSelection =
+ input.action.modelSelection ?? input.modelSelection ?? current.modelSelection;
+ const runtimeMode = input.runtimeMode ?? current.runtimeMode;
+ const interactionMode = input.interactionMode ?? current.interactionMode;
+ const dispatchCommandId = yield* allocateCommandId(input);
+ if (!modelSelectionsEqual(modelSelection, current.modelSelection)) {
+ yield* updateThreadMetadata({
+ commandId: CommandId.make(`${dispatchCommandId}:model-selection`),
+ threadId: input.threadId,
+ modelSelection,
+ });
+ }
+ if (runtimeMode !== current.runtimeMode) {
+ yield* setThreadRuntimeMode({
+ commandId: CommandId.make(`${dispatchCommandId}:runtime-mode`),
+ threadId: input.threadId,
+ runtimeMode,
+ });
+ }
+ if (interactionMode !== current.interactionMode) {
+ yield* setThreadInteractionMode({
+ commandId: CommandId.make(`${dispatchCommandId}:interaction-mode`),
+ threadId: input.threadId,
+ interactionMode,
+ });
+ }
+ const crypto = yield* Crypto.Crypto;
+ return yield* startThreadTurn({
+ ...input,
+ commandId: dispatchCommandId,
+ modelSelection,
+ message: {
+ messageId: MessageId.make(yield* crypto.randomUUIDv4().pipe(Effect.orDie)),
+ role: "user",
+ text: input.action.prompt,
+ attachments: [],
+ },
+ runtimeMode,
+ interactionMode,
+ // Resolve the thread's current selection and approvals on the server, as for a normal send.
+ dispatchMode: "queue",
+ });
+});
+
export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* (
input: InterruptThreadTurnInput,
) {
diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts
index 84a290057f578..ebb295372b56c 100644
--- a/packages/client-runtime/src/state/threadCommands.ts
+++ b/packages/client-runtime/src/state/threadCommands.ts
@@ -45,6 +45,7 @@ import {
type SettleThreadInput,
type SnoozeThreadInput,
type StartThreadTurnInput,
+ type RunProjectPromptInput,
type StopThreadSessionInput,
type UnarchiveThreadInput,
type CloseGuestShareInput,
@@ -85,6 +86,7 @@ import {
settleThread,
snoozeThread,
startThreadTurn,
+ runProjectPrompt,
stopThreadSession,
unarchiveThread,
unlinkThreadPullRequest,
@@ -331,6 +333,12 @@ export function createThreadEnvironmentAtoms(
scheduler,
concurrency,
}),
+ runProjectPrompt: createEnvironmentCommand(runtime, {
+ label: "environment-data:commands:thread:run-project-prompt",
+ execute: (input: RunProjectPromptInput) => runProjectPrompt(input),
+ scheduler,
+ concurrency,
+ }),
startTurn: createEnvironmentCommand(runtime, {
label: "environment-data:commands:thread:start-turn",
execute: (input: StartThreadTurnInput) => startThreadTurn(input),
diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts
index 168886ebe441a..bef91e67a75c1 100644
--- a/packages/contracts/src/project.test.ts
+++ b/packages/contracts/src/project.test.ts
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vite-plus/test";
import { OrchestrationProjectShell } from "./orchestrationProject.ts";
import {
+ ProjectScript,
ProjectFaviconPath,
ReceivedProjectIcon,
StoredProjectIcon,
@@ -177,3 +178,47 @@ effectIt.effect("an icon kind from a newer server shows the default icon", () =>
assert.strictEqual(broken._tag, "Failure");
}),
);
+
+describe("project action persistence", () => {
+ const shell = {
+ id: "test",
+ name: "Test",
+ command: "npm test",
+ icon: "test",
+ runOnWorktreeCreate: true,
+ async: false,
+ previewUrl: "http://localhost:5173",
+ autoOpenPreview: true,
+ };
+ const modelSelection = {
+ instanceId: "custom-provider",
+ model: "deepseek/deepseek-v4.1-flash:free",
+ options: [{ id: "reasoningEffort", value: "max" }],
+ };
+ const prompt = {
+ id: "status",
+ name: "Status",
+ type: "prompt",
+ prompt: "Inspect outputs.\nDo not edit files or submit jobs.",
+ modelSelection,
+ icon: "play",
+ runOnWorktreeCreate: false,
+ };
+
+ it("round trips mixed prompt and legacy shell actions without rewriting shell settings", () => {
+ const json = Schema.fromJsonString(Schema.Array(ProjectScript));
+ const decode = Schema.decodeUnknownSync(json);
+ const actions = decode(JSON.stringify([shell, prompt]));
+ expect(actions).toEqual([shell, prompt]);
+ expect(decode(Schema.encodeSync(json)(actions))).toEqual([shell, prompt]);
+ expect(actions[1]).not.toHaveProperty("command");
+ });
+
+ it("rejects empty prompts and automatic shell lifecycle roles on prompts", () => {
+ const decode = Schema.decodeUnknownSync(ProjectScript);
+ expect(() => decode({ ...prompt, prompt: " " })).toThrow();
+ expect(() => decode({ ...prompt, runOnWorktreeCreate: true })).toThrow();
+ expect(() => decode({ ...prompt, runOnSettle: true })).toThrow();
+ expect(() => decode({ ...prompt, prompt: undefined, command: "echo unsafe" })).toThrow();
+ });
+});
diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts
index 2647519df10aa..98bb0b770d123 100644
--- a/packages/contracts/src/project.ts
+++ b/packages/contracts/src/project.ts
@@ -30,17 +30,32 @@ export const ProjectScriptIcon = Schema.Literals([
]);
export type ProjectScriptIcon = typeof ProjectScriptIcon.Type;
-export const ProjectScript = Schema.Struct({
+const ProjectScriptFields = {
id: TrimmedNonEmptyString,
name: TrimmedNonEmptyString,
- command: TrimmedNonEmptyString,
icon: ProjectScriptIcon,
- runOnWorktreeCreate: Schema.Boolean,
runOnSettle: Schema.optional(Schema.Boolean),
async: Schema.optional(Schema.Boolean),
previewUrl: Schema.optional(TrimmedNonEmptyString),
autoOpenPreview: Schema.optional(Schema.Boolean),
-});
+};
+
+export const ProjectScript = Schema.Union([
+ Schema.Struct({
+ ...ProjectScriptFields,
+ type: Schema.optional(Schema.Literal("shell")),
+ command: TrimmedNonEmptyString,
+ runOnWorktreeCreate: Schema.Boolean,
+ }),
+ Schema.Struct({
+ ...ProjectScriptFields,
+ type: Schema.Literal("prompt"),
+ prompt: TrimmedNonEmptyString,
+ modelSelection: Schema.optional(ModelSelection),
+ runOnWorktreeCreate: Schema.Literal(false),
+ runOnSettle: Schema.optional(Schema.Literal(false)),
+ }),
+]);
export type ProjectScript = typeof ProjectScript.Type;
export const ProjectIconColor = Schema.Literals([
diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts
index af43f78c93409..bbf740951e7ad 100644
--- a/packages/shared/src/projectScripts.ts
+++ b/packages/shared/src/projectScripts.ts
@@ -64,20 +64,29 @@ export function projectScriptRuntimeEnv(
return env;
}
-export function setupProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null {
- return scripts.find((script) => script.runOnWorktreeCreate) ?? null;
+export function setupProjectScript(scripts: readonly ProjectScript[]) {
+ return (
+ scripts
+ .filter((script) => script.type !== "prompt")
+ .find((script) => script.runOnWorktreeCreate) ?? null
+ );
}
export function projectScriptMenuLabel(script: ProjectScript): string {
const roles = [
+ ...(script.type === "prompt" ? ["prompt"] : []),
...(script.runOnWorktreeCreate ? ["setup"] : []),
...(script.runOnSettle ? ["on settle"] : []),
];
return roles.length === 0 ? script.name : `${script.name} (${roles.join(", ")})`;
}
-export function settleProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null {
- return scripts.find((script) => script.runOnSettle === true) ?? null;
+export function settleProjectScript(scripts: readonly ProjectScript[]) {
+ return (
+ scripts
+ .filter((script) => script.type !== "prompt")
+ .find((script) => script.runOnSettle === true) ?? null
+ );
}
/**