diff --git a/README.md b/README.md index 5bc3401dcdc9..277f7a7a7165 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,9 @@ This is a personal fork of [pingdotgg/t3code](https://github.com/pingdotgg/t3cod - **Open threads from links.** The desktop app opens `t3code://app//` links (`t3code-dev://` for Dev builds) from other apps, whether it is already running or starts from the link. Use `primary` as the environment ID for the desktop's own environment. Archived threads open too, with an archived notice and an Unarchive button above the composer. A link to an unknown environment or thread shows a short error instead. -- **Archive when done.** Archiving a working thread, from its menu, the command palette, the archive shortcut, mobile, or the `/t3-archive` composer command, waits until the turn, its checkpoint, and any subagents or monitors it left running finish. Automatic follow-ups such as subagent results don't cancel it; a new message does. A pending archive shows an archive icon on the thread's row, and the same actions cancel it. Agents can archive their own thread with `archive_thread` (inspected or cancelled with `archive_thread_status` and `cancel_thread_archive`), optionally removing its clean worktree while keeping the branch. Requests survive restarts. Sending a message to an archived thread unarchives it first. +- **Archive when done.** Archiving a working thread, from its menu, the command palette, the archive shortcut, mobile, or the `/t3-archive` composer command, waits until the turn, its checkpoint, and any subagents or monitors it left running finish. Automatic follow-ups such as subagent results don't cancel it; a new message does. A pending archive shows an archive icon on the thread's row, and the same actions cancel it. Agents can archive their own thread, or pass `threadId` to archive another one they have read and no longer need, with `archive_thread` (inspected or cancelled with `archive_thread_status` and `cancel_thread_archive`), optionally removing its clean worktree while keeping the branch. Requests survive restarts. Sending a message to an archived thread unarchives it first. +- **Agents edit project actions.** Agents list, add, update, and remove a project's action buttons with `t3_project_actions_list`, `t3_project_actions_upsert`, and `t3_project_actions_remove`. Edits change the same actions the app shows, keep only one worktree setup action, and fail instead of overwriting a concurrent change. Changing actions needs a full-access agent. - **Open a project's dev server from an action.** In the desktop app, give an action a Preview URL and turn on **Open in browser, reusing a running dev server**. If a web server is already running from the project or worktree directory, clicking the action opens it in the in-app browser without running the command again. Otherwise it runs the command, waits up to a minute for the server, and opens it. Only the URL's path matters, so Vite moving to another port is fine. Detection uses `lsof` on macOS and Linux; on Windows, or for servers in Docker, the action runs the command and opens the configured URL. ### Conversations & threads diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 56a808c36f39..f8c66f13fad8 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -49,6 +49,9 @@ import { ArchiveToolkitHandlersLive } from "./toolkits/archive/handlers.ts"; import { ArchiveToolkit } from "./toolkits/archive/tools.ts"; import { WorktreeSwitchToolkitHandlersLive } from "./toolkits/worktreeSwitch/handlers.ts"; import { WorktreeSwitchToolkit } from "./toolkits/worktreeSwitch/tools.ts"; +import * as ProjectActionsHandlers from "./toolkits/projectActions/handlers.ts"; +import { ProjectActionsToolkit } from "./toolkits/projectActions/tools.ts"; +import * as ProjectActions from "../project/ProjectActions.ts"; import * as WorktreeMcpService from "./WorktreeMcpService.ts"; import { VoiceToolkitHandlersLive } from "./toolkits/voice/handlers.ts"; import { VoiceToolkit } from "./toolkits/voice/tools.ts"; @@ -729,7 +732,7 @@ const layerWorktreeToolkitRegistration = McpServer.toolkit(WorktreeToolkit).pipe Layer.provide(WorktreeHandlers.layer), ); -// Fork: deferred archive tools for the credential's own thread. +// Fork: deferred archive tools for the credential's own thread or a target thread. const layerArchiveToolkitRegistration = McpServer.toolkit(ArchiveToolkit).pipe( Layer.provide(ArchiveToolkitHandlersLive), ); @@ -739,6 +742,12 @@ const layerWorktreeSwitchToolkitRegistration = McpServer.toolkit(WorktreeSwitchT Layer.provide(WorktreeSwitchToolkitHandlersLive), ); +// Fork: project actions (run buttons), stored in server settings. +const layerProjectActionsToolkitRegistration = McpServer.toolkit(ProjectActionsToolkit).pipe( + Layer.provide(ProjectActionsHandlers.layer), + Layer.provide(ProjectActions.layer), +); + const layerPreviewControlsRegistration = McpServer.toolkit(PreviewControlsToolkit).pipe( Layer.provide(PreviewControlsHandlers.layer), ); @@ -822,6 +831,7 @@ const mcpToolkitIsland = (path: `/${string}`, registrations: Layer.Layer, options: { readonly callerLive?: boolean } = {}) => { + const shell = (id: ThreadId) => + ({ + id, + projectId: ProjectId.make("project-archive-tools"), + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: id === broadId ? "full-access" : "auto-accept-edits", + interactionMode: "default", + activeRunId: id === threadId && options.callerLive === false ? null : RunId.make("run-1"), + archivedAt: id === targetId ? archivedAt : null, + deletedAt: null, + }) as unknown as OrchestrationV2ThreadShell; + const record = (operation: string) => (id: ThreadId) => { + calls.push(`${operation}:${id}`); + return Effect.succeed({ archivedAt: null, request }); + }; + return McpServer.toolkit(ArchiveToolkit).pipe( + Layer.provide(ArchiveToolkitHandlersLive), + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: (id) => Effect.succeed(shell(id)), + getProjectThreadRecords: (input) => + Effect.succeed({ thread: shell(input.threadId) } as never), + }), + ), + Layer.provideMerge( + Layer.mock(ThreadArchiveScheduler.ThreadArchiveScheduler)({ + schedule: (input) => { + calls.push(`schedule:${input.threadId}:${input.afterTurn}:${input.removeWorktree}`); + return Effect.succeed({ archivedAt: null, request }); + }, + status: record("status"), + cancel: (input) => record("cancel")(input.threadId), + }), + ), + ); +}; + +it.effect("schedules, reads, and cancels another thread's archive", () => { + const calls: Array = []; + return Effect.gen(function* () { + for (const [name, args] of [ + ["archive_thread", { threadId: targetId, removeWorktree: true }], + ["archive_thread_status", { threadId: targetId }], + ["cancel_thread_archive", { threadId: targetId }], + ] as const) { + expect((yield* call(name, args)).isError).toBe(false); + } + expect(calls).toEqual([ + `schedule:${targetId}:true:true`, + `status:${targetId}`, + `cancel:${targetId}`, + ]); + }).pipe(Effect.scoped, Effect.provide(makeTargetLayer(calls))); +}); + +it.effect("refuses to change a target with broader modes but still reads its status", () => { + const calls: Array = []; + return Effect.gen(function* () { + for (const name of ["archive_thread", "cancel_thread_archive"]) { + expect(declaredFailure(yield* call(name, { threadId: broadId }))).toMatchObject({ + code: "runtime_mode_escalation_denied", + }); + } + expect((yield* call("archive_thread_status", { threadId: broadId })).isError).toBe(false); + expect(calls).toEqual([`status:${broadId}`]); + }).pipe(Effect.scoped, Effect.provide(makeTargetLayer(calls))); +}); + +it.effect("refuses to archive another thread once the caller's run ended", () => { + const calls: Array = []; + return Effect.gen(function* () { + expect(declaredFailure(yield* call("archive_thread", { threadId: targetId }))).toMatchObject({ + code: "parent_not_active", + }); + expect(calls).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(makeTargetLayer(calls, { callerLive: false }))); +}); diff --git a/apps/server/src/mcp/toolkits/archive/handlers.ts b/apps/server/src/mcp/toolkits/archive/handlers.ts index 83805c8bd0b8..4c2e913fe6f3 100644 --- a/apps/server/src/mcp/toolkits/archive/handlers.ts +++ b/apps/server/src/mcp/toolkits/archive/handlers.ts @@ -1,9 +1,9 @@ -import { OrchestratorMcpFailure } from "@t3tools/contracts"; +import { OrchestratorMcpFailure, type ThreadId } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as ThreadArchiveScheduler from "../../../orchestration-v2/ThreadArchiveScheduler.ts"; -import { readOwnedCaller } from "../../threadAccess.ts"; +import { readOwnedCaller, readThread, readWritableThread } from "../../threadAccess.ts"; import { ArchiveToolkit } from "./tools.ts"; const toResult = (status: ThreadArchiveScheduler.ThreadArchiveStatus) => ({ @@ -14,33 +14,38 @@ const toResult = (status: ThreadArchiveScheduler.ThreadArchiveStatus) => ({ const failure = (error: ThreadArchiveScheduler.ThreadArchiveSchedulerError) => new OrchestratorMcpFailure({ code: "invalid_request", message: error.detail }); +/** + * The credential's own thread when omitted (readable after it is archived); + * otherwise any thread for status, or a writable one within the caller's modes. + */ +const resolveTarget = (threadId: ThreadId | undefined, write: boolean) => + threadId === undefined + ? readOwnedCaller().pipe(Effect.map((thread) => thread.id)) + : (write ? readWritableThread(threadId) : readThread(threadId)).pipe( + Effect.map((context) => context.projection.thread.id), + ); + export const ArchiveToolkitHandlersLive = ArchiveToolkit.toLayer({ archive_thread: (input) => Effect.gen(function* () { - const thread = yield* readOwnedCaller(); + const threadId = yield* resolveTarget(input.threadId, true); const scheduler = yield* ThreadArchiveScheduler.ThreadArchiveScheduler; return toResult( yield* scheduler - .schedule({ - threadId: thread.id, - afterTurn: true, - removeWorktree: input.removeWorktree === true, - }) + .schedule({ threadId, afterTurn: true, removeWorktree: input.removeWorktree === true }) .pipe(Effect.mapError(failure)), ); }), - archive_thread_status: () => + archive_thread_status: (input) => Effect.gen(function* () { - const thread = yield* readOwnedCaller(); + const threadId = yield* resolveTarget(input.threadId, false); const scheduler = yield* ThreadArchiveScheduler.ThreadArchiveScheduler; - return toResult(yield* scheduler.status(thread.id).pipe(Effect.mapError(failure))); + return toResult(yield* scheduler.status(threadId).pipe(Effect.mapError(failure))); }), - cancel_thread_archive: () => + cancel_thread_archive: (input) => Effect.gen(function* () { - const thread = yield* readOwnedCaller(); + const threadId = yield* resolveTarget(input.threadId, true); const scheduler = yield* ThreadArchiveScheduler.ThreadArchiveScheduler; - return toResult( - yield* scheduler.cancel({ threadId: thread.id }).pipe(Effect.mapError(failure)), - ); + return toResult(yield* scheduler.cancel({ threadId }).pipe(Effect.mapError(failure))); }), }); diff --git a/apps/server/src/mcp/toolkits/archive/tools.ts b/apps/server/src/mcp/toolkits/archive/tools.ts index 740024e5509f..38a0492b7a6c 100644 --- a/apps/server/src/mcp/toolkits/archive/tools.ts +++ b/apps/server/src/mcp/toolkits/archive/tools.ts @@ -2,6 +2,7 @@ import { IsoDateTime, OrchestrationV2ThreadArchiveRequest, OrchestratorMcpFailure, + ThreadId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/ai"; @@ -10,7 +11,7 @@ import * as ThreadArchiveScheduler from "../../../orchestration-v2/ThreadArchive import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; -/** Fork: deferred archive tools for the credential's own thread. */ +/** Fork: deferred archive tools for the credential's own thread or a target thread. */ export const ArchiveToolResult = Schema.Struct({ archivedAt: Schema.NullOr(IsoDateTime), request: Schema.NullOr(OrchestrationV2ThreadArchiveRequest), @@ -22,13 +23,21 @@ const dependencies = [ ThreadArchiveScheduler.ThreadArchiveScheduler, ]; +const threadId = Schema.optional( + ThreadId.annotate({ + description: + "Target another thread, in any project. Omit for this thread. Use it, for example, to read thread X's messages with t3_thread_read and then archive X once it is no longer needed.", + }), +); + const ArchiveThread = Tool.make("archive_thread", { description: - "When the user asks to archive this thread when you are done, schedule its archive after the current turn succeeds, its final checkpoint lands, and background work such as subagents and monitors finishes. Only set removeWorktree=true when the user also asks to remove the worktree. Removal preserves the branch and refuses dirty, locked, detached, shared, or project-checkout worktrees; a refusal after archiving leaves the thread archived and records the reason. A pending request means scheduled, not archived: finish your response without waiting for your own turn to end. Failed or interrupted turns, a Stop, or a new message from the user or an agent cancel the request; automatic wakes such as subagent results or background notifications are waited through. Idle threads archive immediately. Use archive_thread_status to inspect or cancel_thread_archive to cancel before archiving starts.", + "Archive a thread once its current turn succeeds, its final checkpoint lands, and background work such as subagents and monitors finishes; an idle thread archives immediately. Without threadId this targets your own thread: use it when the user asks to archive this thread when you are done, then finish your response without waiting for your own turn to end (a pending request means scheduled, not archived). With threadId it archives that other thread, which must run within your own runtime and interaction modes. Only set removeWorktree=true when the user also asks to remove the worktree. Removal preserves the branch and refuses dirty, locked, detached, shared, or project-checkout worktrees; a refusal after archiving leaves the thread archived and records the reason. Failed or interrupted turns, a Stop, or a new message from the user or an agent to the target thread cancel the request; automatic wakes such as subagent results or background notifications are waited through. Use archive_thread_status to inspect or cancel_thread_archive to cancel before archiving starts.", parameters: Schema.Struct({ + threadId, removeWorktree: Schema.optional( Schema.Boolean.annotate({ - description: "Also remove this thread's clean worktree after archiving. Defaults to false.", + description: "Also remove the thread's clean worktree after archiving. Defaults to false.", }), ), }), @@ -37,14 +46,15 @@ const ArchiveThread = Tool.make("archive_thread", { failureMode: "return", dependencies, }) - .annotate(Tool.Title, "Archive this thread when done") + .annotate(Tool.Title, "Archive a thread when done") .annotate(Tool.Readonly, false) .annotate(Tool.Destructive, true) .annotate(Tool.OpenWorld, false); const ArchiveThreadStatus = Tool.make("archive_thread_status", { description: - "Read this thread's archive state and latest archive request, including pending, completed, cancelled, or error status and its detail. A pending request verifies scheduling; finish your turn so it can run. On an archived thread, pending means its worktree removal is still running, and error carries why the worktree was kept. Null means no archive request exists.", + "Read a thread's archive state and latest archive request, including pending, completed, cancelled, or error status and its detail; works for archived threads. Omit threadId for this thread, where a pending request verifies scheduling and you should finish your turn so it can run. On an archived thread, pending means its worktree removal is still running, and error carries why the worktree was kept. Null means no archive request exists.", + parameters: Schema.Struct({ threadId }), success: ArchiveToolResult, failure: OrchestratorMcpFailure, failureMode: "return", @@ -57,7 +67,8 @@ const ArchiveThreadStatus = Tool.make("archive_thread_status", { const CancelThreadArchive = Tool.make("cancel_thread_archive", { description: - "Cancel this thread's pending archive before archiving starts. Does not restore an archived thread. Returns the current state when there is no pending request.", + "Cancel a thread's pending archive before archiving starts; omit threadId for this thread. Does not restore an archived thread. Returns the current state when there is no pending request.", + parameters: Schema.Struct({ threadId }), success: ArchiveToolResult, failure: OrchestratorMcpFailure, failureMode: "return", diff --git a/apps/server/src/mcp/toolkits/projectActions/handlers.test.ts b/apps/server/src/mcp/toolkits/projectActions/handlers.test.ts new file mode 100644 index 000000000000..4b83f2377825 --- /dev/null +++ b/apps/server/src/mcp/toolkits/projectActions/handlers.test.ts @@ -0,0 +1,131 @@ +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + type OrchestrationV2ThreadShell, + type Project, + ProjectId, + ProviderInstanceId, + RunId, + type RuntimeMode, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { McpSchema, McpServer } from "effect/ai"; + +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectActions from "../../../project/ProjectActions.ts"; +import * as ProjectService from "../../../project/ProjectService.ts"; +import * as ServerSettings from "../../../serverSettings.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as ProjectActionsHandlers from "./handlers.ts"; +import { ProjectActionsToolkit } from "./tools.ts"; + +const threadId = ThreadId.make("thread-project-actions"); +const projectId = ProjectId.make("project-actions-tools"); +const client = McpSchema.McpServerClient.of({ + clientId: 1, + clientCapabilities: {}, + clientInfo: { name: "mcp-test", version: "1.0.0" }, + protocolVersion: "2025-06-18", + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "mcp-test", version: "1.0.0" }, + }, + getClient: Effect.die("unused"), +}); + +const declaredFailure = (result: McpSchema.CallToolResult) => { + const text = result.content[0]; + return result.isError === true && text?.type === "text" ? JSON.parse(text.text) : undefined; +}; + +const makeLayer = (runtimeMode: RuntimeMode) => + McpServer.toolkit(ProjectActionsToolkit).pipe( + Layer.provide(ProjectActionsHandlers.layer), + Layer.provide(ProjectActions.layer), + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide( + Layer.mock(ProjectService.ProjectService)({ + getById: () => + Effect.succeed( + Option.some({ + id: projectId, + title: "Tools", + workspaceRoot: "/repo", + scripts: [], + } as unknown as Project), + ), + }), + ), + Layer.provide(ServerSettings.layerTest({ projectSettingsFolded: true })), + Layer.provideMerge( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadShell: () => + Effect.succeed({ + id: threadId, + projectId, + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode, + interactionMode: "default", + activeRunId: RunId.make("run-1"), + archivedAt: null, + deletedAt: null, + } as unknown as OrchestrationV2ThreadShell), + }), + ), + ); + +const call = (name: string, args: Record = {}) => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + return yield* server.callTool({ name, arguments: args }).pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, { + environmentId: EnvironmentId.make("environment-project-actions"), + requestNamespace: "provider-session-project-actions", + thread: { + threadId, + providerSessionId: "provider-session-project-actions", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"] as const), + issuedAt: 1, + }), + Effect.provideService(McpSchema.McpServerClient, client), + ); + }); + +it.effect("edits the calling thread's project actions as a full-access caller", () => + Effect.gen(function* () { + const created = yield* call("t3_project_actions_upsert", { name: "Test", command: "vp test" }); + expect(created.structuredContent).toMatchObject({ + projectId, + action: "created", + projectAction: { id: "test", command: "vp test" }, + }); + const listed = yield* call("t3_project_actions_list"); + expect(listed.structuredContent).toMatchObject({ + projectId, + title: "Tools", + actions: [{ id: "test" }], + }); + expect( + declaredFailure(yield* call("t3_project_actions_remove", { actionId: "lint" })), + ).toMatchObject({ code: "invalid_request", message: expect.stringContaining("'lint'") }); + expect( + (yield* call("t3_project_actions_remove", { actionId: "test" })).structuredContent, + ).toEqual({ projectId, action: "removed", actionId: "test" }); + }).pipe(Effect.scoped, Effect.provide(makeLayer("full-access"))), +); + +it.effect("lets a narrower caller list but not change actions", () => + Effect.gen(function* () { + expect((yield* call("t3_project_actions_list")).isError).toBe(false); + expect( + declaredFailure(yield* call("t3_project_actions_upsert", { name: "Test", command: "x" })), + ).toMatchObject({ code: "capability_denied" }); + }).pipe(Effect.scoped, Effect.provide(makeLayer("auto-accept-edits"))), +); diff --git a/apps/server/src/mcp/toolkits/projectActions/handlers.ts b/apps/server/src/mcp/toolkits/projectActions/handlers.ts new file mode 100644 index 000000000000..5880fe4b3259 --- /dev/null +++ b/apps/server/src/mcp/toolkits/projectActions/handlers.ts @@ -0,0 +1,51 @@ +import { OrchestratorMcpFailure, type ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import * as ProjectActions from "../../../project/ProjectActions.ts"; +import { + readCaller, + readFullAccessCaller, + resolveProjectId, + unavailable, +} from "../../threadAccess.ts"; +import { ProjectActionsToolkit } from "./tools.ts"; + +const failure = (error: ProjectActions.ProjectActionsError) => + error._tag === "ProjectOperationError" || error._tag === "ServerSettingsError" + ? unavailable() + : new OrchestratorMcpFailure({ code: "invalid_request", message: error.message }); + +/** Reading needs any orchestration caller; edits, like other project changes, need full access. */ +const resolveTarget = (projectId: ProjectId | undefined, write: boolean) => + Effect.gen(function* () { + const context = write + ? yield* readFullAccessCaller( + "Project action changes require a live full-access/default calling thread or a full-access client.", + ) + : yield* readCaller(); + return yield* resolveProjectId(context, projectId); + }); + +/** Fork: project actions toolkit. */ +export const layer = ProjectActionsToolkit.toLayer({ + t3_project_actions_list: (input) => + Effect.gen(function* () { + const projectId = yield* resolveTarget(input.projectId, false); + const actions = yield* ProjectActions.ProjectActions; + return yield* actions.list(projectId).pipe(Effect.mapError(failure)); + }), + t3_project_actions_upsert: (input) => + Effect.gen(function* () { + const projectId = yield* resolveTarget(input.projectId, true); + const actions = yield* ProjectActions.ProjectActions; + return yield* actions.upsert({ ...input, projectId }).pipe(Effect.mapError(failure)); + }), + t3_project_actions_remove: (input) => + Effect.gen(function* () { + const projectId = yield* resolveTarget(input.projectId, true); + const actions = yield* ProjectActions.ProjectActions; + return yield* actions + .remove({ projectId, actionId: input.actionId }) + .pipe(Effect.mapError(failure)); + }), +}); diff --git a/apps/server/src/mcp/toolkits/projectActions/tools.ts b/apps/server/src/mcp/toolkits/projectActions/tools.ts new file mode 100644 index 000000000000..6dd1d80d0e56 --- /dev/null +++ b/apps/server/src/mcp/toolkits/projectActions/tools.ts @@ -0,0 +1,113 @@ +import { + OrchestratorMcpFailure, + ProjectId, + ProjectScript, + ProjectScriptIcon, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/ai"; + +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectActions from "../../../project/ProjectActions.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +/** + * Fork: edit the project actions (run buttons) the app shows. They live in + * server settings, not in the project row `t3_project_update` writes. + */ +const shared = { + failure: OrchestratorMcpFailure, + failureMode: "return" as const, + dependencies: [ + McpInvocationContext.McpInvocationContext, + ThreadManagementService.ThreadManagementService, + ProjectActions.ProjectActions, + ], +}; + +const projectId = Schema.optional( + ProjectId.annotate({ description: "Target project. Defaults to the calling thread's project." }), +); + +const ProjectActionsListTool = Tool.make("t3_project_actions_list", { + ...shared, + description: + "List a project's actions: the run buttons the app shows for it (from project overrides or environment defaults). Read them before changing them; t3_project_read's scripts are a legacy field, not these actions.", + parameters: Schema.Struct({ projectId }), + success: Schema.Struct({ + projectId: ProjectId, + title: Schema.String, + workspaceRoot: Schema.String, + actions: Schema.Array(ProjectScript), + }), +}) + .annotate(Tool.Title, "List project actions") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.OpenWorld, false); + +const ProjectActionsUpsertTool = Tool.make("t3_project_actions_upsert", { + ...shared, + description: + "Create or update a project action. Omit actionId to create one (name and command required; its id derives from the name). Pass an actionId from t3_project_actions_list to update it; omitted fields keep their values. Only one action may run on worktree creation: setting runOnWorktreeCreate clears it elsewhere and reports those ids in clearedRunOnWorktreeCreate. Inherited default actions become this project's own actions on the first change. The read and write are atomic, but a list result can be stale: re-list before updating when another client may have edited the actions. Fails with a conflict when they change mid-write; list them again and retry. Requires a full-access/default caller.", + parameters: Schema.Struct({ + projectId, + actionId: Schema.optional( + TrimmedNonEmptyString.annotate({ description: "Existing action id to update." }), + ), + name: Schema.optional(TrimmedNonEmptyString), + command: Schema.optional( + TrimmedNonEmptyString.annotate({ description: "Shell command the action runs." }), + ), + icon: Schema.optional(ProjectScriptIcon), + runOnWorktreeCreate: Schema.optional( + Schema.Boolean.annotate({ description: "Run automatically after creating a worktree." }), + ), + async: Schema.optional( + Schema.Boolean.annotate({ + description: "For a worktree setup action: let the agent start while it runs.", + }), + ), + previewUrl: Schema.optional( + Schema.NullOr(TrimmedNonEmptyString).annotate({ + description: "Desktop preview URL to open for the action. null removes it.", + }), + ), + autoOpenPreview: Schema.optional( + Schema.Boolean.annotate({ description: "Open the preview URL automatically." }), + ), + }), + success: Schema.Struct({ + projectId: ProjectId, + action: Schema.Literals(["created", "updated"]), + projectAction: ProjectScript, + clearedRunOnWorktreeCreate: Schema.Array(Schema.String), + }), +}) + .annotate(Tool.Title, "Create or update a project action") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.OpenWorld, false); + +const ProjectActionsRemoveTool = Tool.make("t3_project_actions_remove", { + ...shared, + description: + "Remove a project action by its id from t3_project_actions_list. Fails if the actions changed since they were read; list them again and retry. Requires a full-access/default caller.", + parameters: Schema.Struct({ projectId, actionId: TrimmedNonEmptyString }), + success: Schema.Struct({ + projectId: ProjectId, + action: Schema.Literal("removed"), + actionId: Schema.String, + }), +}) + .annotate(Tool.Title, "Remove a project action") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.OpenWorld, false); + +export const ProjectActionsToolkit = Toolkit.make( + ProjectActionsListTool, + ProjectActionsUpsertTool, + ProjectActionsRemoveTool, +); diff --git a/apps/server/src/mcp/toolkits/voice/registration.test.ts b/apps/server/src/mcp/toolkits/voice/registration.test.ts index 3f18fa6ef848..b2a25c267260 100644 --- a/apps/server/src/mcp/toolkits/voice/registration.test.ts +++ b/apps/server/src/mcp/toolkits/voice/registration.test.ts @@ -132,8 +132,16 @@ it.effect("voice_reply exists only for credentials with the voice capability", ( new Set(["preview", "device"]), ]) { const session = yield* mcpSession(capabilities); - expect(toolNames(yield* session.call(2, "tools/list", {})), session.path).not.toContain( - "voice_reply", + const names = toolNames(yield* session.call(2, "tools/list", {})); + expect(names, session.path).not.toContain("voice_reply"); + // Fork toolkits that need no extra capability ride on every island. + expect(names, session.path).toEqual( + expect.arrayContaining([ + "archive_thread", + "t3_project_actions_list", + "t3_project_actions_upsert", + "t3_project_actions_remove", + ]), ); } diff --git a/apps/server/src/project/ProjectActions.test.ts b/apps/server/src/project/ProjectActions.test.ts new file mode 100644 index 000000000000..10043bb04a76 --- /dev/null +++ b/apps/server/src/project/ProjectActions.test.ts @@ -0,0 +1,161 @@ +import { expect, it } from "@effect/vitest"; +import { + type Project, + ProjectId, + type ProjectScript, + type ServerSettingsError, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ServerSettings from "../serverSettings.ts"; +import * as ProjectActions from "./ProjectActions.ts"; +import * as ProjectService from "./ProjectService.ts"; + +const projectId = ProjectId.make("project-actions"); +const setup: ProjectScript = { + id: "setup", + name: "Setup", + command: "pnpm install", + icon: "configure", + runOnWorktreeCreate: true, + async: false, +}; +const dev: ProjectScript = { + id: "dev", + name: "Dev", + command: "pnpm dev", + icon: "play", + runOnWorktreeCreate: false, + previewUrl: "http://localhost:5173", + autoOpenPreview: true, +}; + +const projectLayer = Layer.mock(ProjectService.ProjectService)({ + getById: (id) => + Effect.succeed( + id === projectId + ? Option.some({ + id: projectId, + title: "Actions", + workspaceRoot: "/repo", + scripts: [], + } as unknown as Project) + : Option.none(), + ), +}); + +/** Environment defaults hold the actions, so the project starts out inheriting them. */ +const settingsLayer = ServerSettings.layerTest({ + projectSettingsFolded: true, + defaultProjectScripts: [setup, dev], +}); + +const makeLayer = ( + settings: Layer.Layer = settingsLayer, +) => ProjectActions.layer.pipe(Layer.provideMerge(settings), Layer.provide(projectLayer)); + +const service = ProjectActions.ProjectActions; + +it.effect("creates an action with a collision-safe id as a project override", () => + Effect.gen(function* () { + const actions = yield* service; + const result = yield* actions.upsert({ projectId, name: " Dev ", command: "pnpm dev:web" }); + expect(result).toEqual({ + projectId, + action: "created", + projectAction: { + id: "dev-2", + name: "Dev", + command: "pnpm dev:web", + icon: "play", + runOnWorktreeCreate: false, + }, + clearedRunOnWorktreeCreate: [], + }); + expect((yield* actions.list(projectId)).actions.map((action) => action.id)).toEqual([ + "setup", + "dev", + "dev-2", + ]); + // The environment defaults stay as they were. + const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings; + expect(settings.defaultProjectScripts).toEqual([setup, dev]); + }).pipe(Effect.provide(makeLayer())), +); + +it.effect("updates preserve omitted fields and null clears the preview", () => + Effect.gen(function* () { + const actions = yield* service; + const renamed = yield* actions.upsert({ projectId, actionId: "setup", name: "Install" }); + expect(renamed.projectAction).toEqual({ ...setup, name: "Install" }); + const cleared = yield* actions.upsert({ projectId, actionId: "dev", previewUrl: null }); + expect(cleared.projectAction).toEqual({ + id: "dev", + name: "Dev", + command: "pnpm dev", + icon: "play", + runOnWorktreeCreate: false, + }); + const failure = yield* actions + .upsert({ projectId, actionId: "dev", autoOpenPreview: true }) + .pipe(Effect.flip); + expect(failure).toBeInstanceOf(ProjectActions.ProjectActionValidationError); + }).pipe(Effect.provide(makeLayer())), +); + +it.effect("keeps a single worktree setup action and reports the cleared one", () => + Effect.gen(function* () { + const actions = yield* service; + const result = yield* actions.upsert({ projectId, actionId: "dev", runOnWorktreeCreate: true }); + expect(result.clearedRunOnWorktreeCreate).toEqual(["setup"]); + const listed = (yield* actions.list(projectId)).actions; + expect( + listed.filter((action) => action.runOnWorktreeCreate).map((action) => action.id), + ).toEqual(["dev"]); + }).pipe(Effect.provide(makeLayer())), +); + +it.effect("removes an action and reports unknown ids", () => + Effect.gen(function* () { + const actions = yield* service; + expect(yield* actions.remove({ projectId, actionId: "dev" })).toEqual({ + projectId, + action: "removed", + actionId: "dev", + }); + expect((yield* actions.list(projectId)).actions).toEqual([setup]); + const missing = yield* actions.remove({ projectId, actionId: "dev" }).pipe(Effect.flip); + expect(missing).toBeInstanceOf(ProjectActions.ProjectActionNotFoundError); + }).pipe(Effect.provide(makeLayer())), +); + +it.effect("reports a conflict when another client changed the actions meanwhile", () => { + // Another client edits the actions right after this service reads them. + const racing = Layer.effect( + ServerSettings.ServerSettingsService, + Effect.gen(function* () { + const real = yield* ServerSettings.ServerSettingsService; + return ServerSettings.ServerSettingsService.of({ + ...real, + getSettings: real.getSettings.pipe( + Effect.tap((current) => + real.updateSettings({ + projectScriptUpdate: { + projectId, + expectedScripts: [...current.defaultProjectScripts], + scripts: [setup], + }, + }), + ), + ), + }); + }), + ).pipe(Layer.provide(settingsLayer)); + return Effect.gen(function* () { + const actions = yield* service; + const failure = yield* actions.remove({ projectId, actionId: "dev" }).pipe(Effect.flip); + expect(failure).toBeInstanceOf(ProjectActions.ProjectActionsConflictError); + }).pipe(Effect.provide(makeLayer(racing))); +}); diff --git a/apps/server/src/project/ProjectActions.ts b/apps/server/src/project/ProjectActions.ts new file mode 100644 index 000000000000..d77a64b244f7 --- /dev/null +++ b/apps/server/src/project/ProjectActions.ts @@ -0,0 +1,313 @@ +/** + * Fork: reads and edits a project's effective actions (the run buttons the UI + * shows), which live in server settings rather than the project row. Writes + * are a compare-and-set against the actions just read, under the settings + * write lock, so a concurrent edit from another client is reported instead of + * overwritten. Inherited default actions become the project's own override on + * the first write. Keybindings are never touched. + */ +import { + type ProjectId, + type ProjectScript, + type ProjectScriptIcon, + ServerSettingsError, +} from "@t3tools/contracts"; +import { + buildProjectScript, + nextProjectScriptId, + normalizeProjectSetupScript, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as ServerSettings from "../serverSettings.ts"; +import * as ProjectService from "./ProjectService.ts"; + +export class ProjectActionsProjectNotFoundError extends Schema.TaggedError()( + "ProjectActionsProjectNotFoundError", + { projectId: Schema.String }, +) { + override get message(): string { + return `Project '${this.projectId}' was not found.`; + } +} + +export class ProjectActionNotFoundError extends Schema.TaggedError()( + "ProjectActionNotFoundError", + { + projectId: Schema.String, + actionId: Schema.String, + availableActionIds: Schema.Array(Schema.String), + }, +) { + override get message(): string { + const available = + this.availableActionIds.length === 0 ? "none" : this.availableActionIds.join(", "); + return `No action '${this.actionId}' exists in project '${this.projectId}' (available: ${available}).`; + } +} + +export class ProjectActionValidationError extends Schema.TaggedError()( + "ProjectActionValidationError", + { + field: Schema.Literals(["name", "command", "previewUrl", "autoOpenPreview"]), + detail: Schema.String, + }, +) { + override get message(): string { + return `Invalid project action ${this.field}: ${this.detail}.`; + } +} + +/** Another client changed the project's actions after they were read. */ +export class ProjectActionsConflictError extends Schema.TaggedError()( + "ProjectActionsConflictError", + { projectId: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Project '${this.projectId}' actions changed since they were read. List them again and retry.`; + } +} + +/** The settings file still holds unfolded legacy project settings. */ +export class ProjectActionsUnavailableError extends Schema.TaggedError()( + "ProjectActionsUnavailableError", + { projectId: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return "Project actions cannot be edited until project settings are folded. Repair the settings file first."; + } +} + +export type ProjectActionsError = + | ProjectActionsProjectNotFoundError + | ProjectActionNotFoundError + | ProjectActionValidationError + | ProjectActionsConflictError + | ProjectActionsUnavailableError + | ProjectService.ProjectOperationError + | ServerSettingsError; + +export interface ProjectActionsList { + readonly projectId: ProjectId; + readonly title: string; + readonly workspaceRoot: string; + readonly actions: ReadonlyArray; +} + +/** No `actionId` creates an action (name and command required); an `actionId` updates it. */ +export interface ProjectActionUpsertInput { + readonly projectId: ProjectId; + readonly actionId?: string | undefined; + readonly name?: string | undefined; + readonly command?: string | undefined; + readonly icon?: ProjectScriptIcon | undefined; + readonly runOnWorktreeCreate?: boolean | undefined; + readonly async?: boolean | undefined; + /** `null` clears the preview URL and its auto-open setting. */ + readonly previewUrl?: string | null | undefined; + readonly autoOpenPreview?: boolean | undefined; +} + +export interface ProjectActionUpsertResult { + readonly projectId: ProjectId; + readonly action: "created" | "updated"; + readonly projectAction: ProjectScript; + /** Actions whose run-on-worktree-create flag was cleared; only one action may hold it. */ + readonly clearedRunOnWorktreeCreate: ReadonlyArray; +} + +export interface ProjectActionRemoveResult { + readonly projectId: ProjectId; + readonly action: "removed"; + readonly actionId: string; +} + +export class ProjectActions extends Context.Service< + ProjectActions, + { + readonly list: (projectId: ProjectId) => Effect.Effect; + readonly upsert: ( + input: ProjectActionUpsertInput, + ) => Effect.Effect; + readonly remove: (input: { + readonly projectId: ProjectId; + readonly actionId: string; + }) => Effect.Effect; + } +>()("t3/project/ProjectActions") {} + +const requiredText = (field: "name" | "command", value: string) => { + const trimmed = value.trim(); + return trimmed.length > 0 + ? Effect.succeed(trimmed) + : Effect.fail(new ProjectActionValidationError({ field, detail: "cannot be empty" })); +}; + +/** `undefined` keeps `current`, `null` clears, a string sets a trimmed non-empty URL. */ +const previewUrlValue = (value: string | null | undefined, current: string | null) => { + if (value === undefined) return Effect.succeed(current); + if (value === null) return Effect.succeed(null); + const trimmed = value.trim(); + return trimmed.length > 0 + ? Effect.succeed(trimmed) + : Effect.fail( + new ProjectActionValidationError({ + field: "previewUrl", + detail: "cannot be empty; pass null to clear it", + }), + ); +}; + +const findAction = ( + projectId: ProjectId, + scripts: ReadonlyArray, + actionId: string, +) => { + const trimmed = actionId.trim(); + const action = scripts.find((candidate) => candidate.id === trimmed); + return action !== undefined + ? Effect.succeed(action) + : Effect.fail( + new ProjectActionNotFoundError({ + projectId, + actionId: trimmed, + availableActionIds: scripts.map((candidate) => candidate.id), + }), + ); +}; + +/** The next action list for an upsert; ids derive from the name and never collide. */ +const applyUpsert = Effect.fn("ProjectActions.applyUpsert")(function* ( + scripts: ReadonlyArray, + input: ProjectActionUpsertInput, +) { + const current = + input.actionId === undefined + ? undefined + : yield* findAction(input.projectId, scripts, input.actionId); + if (current === undefined && (input.name === undefined || input.command === undefined)) { + return yield* new ProjectActionValidationError({ + field: input.name === undefined ? "name" : "command", + detail: "is required to create an action", + }); + } + const name = input.name === undefined ? current!.name : yield* requiredText("name", input.name); + const command = + input.command === undefined ? current!.command : yield* requiredText("command", input.command); + const previewUrl = yield* previewUrlValue(input.previewUrl, current?.previewUrl ?? null); + if (input.autoOpenPreview === true && previewUrl === null) { + return yield* new ProjectActionValidationError({ + field: "autoOpenPreview", + detail: "requires a preview URL", + }); + } + const async = input.async ?? current?.async; + const projectAction = buildProjectScript( + current?.id ?? + nextProjectScriptId( + name, + scripts.map((script) => script.id), + ), + { + name, + command, + icon: input.icon ?? current?.icon ?? "play", + runOnWorktreeCreate: input.runOnWorktreeCreate ?? current?.runOnWorktreeCreate ?? false, + ...(async === undefined ? {} : { async }), + previewUrl, + autoOpenPreview: + previewUrl === null ? false : (input.autoOpenPreview ?? current?.autoOpenPreview ?? false), + }, + ); + const next = + current === undefined + ? [...scripts, projectAction] + : scripts.map((candidate) => (candidate.id === current.id ? projectAction : candidate)); + const normalized = normalizeProjectSetupScript(next, projectAction.id); + return { + projectAction, + created: current === undefined, + scripts: normalized.scripts, + clearedRunOnWorktreeCreate: normalized.clearedActionIds, + }; +}); + +const make = Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const settings = yield* ServerSettings.ServerSettingsService; + + const read = Effect.fn("ProjectActions.read")(function* (projectId: ProjectId) { + const project = yield* projects.getById(projectId); + if (Option.isNone(project)) { + return yield* new ProjectActionsProjectNotFoundError({ projectId }); + } + const scripts = resolveProjectScripts(yield* settings.getSettings, project.value); + return { project: project.value, scripts }; + }); + + /** Compare-and-set against the actions `read` returned. */ + const write = ( + projectId: ProjectId, + expectedScripts: ReadonlyArray, + scripts: ReadonlyArray, + ) => + settings + .updateSettings({ + projectScriptUpdate: { + projectId, + expectedScripts: Array.from(expectedScripts), + scripts: Array.from(scripts), + }, + }) + .pipe( + Effect.catchTags({ + ServerSettingsError: (cause) => + cause.operation === "project-actions-conflict" + ? Effect.fail(new ProjectActionsConflictError({ projectId, cause })) + : cause.operation === "project-actions-unavailable" + ? Effect.fail(new ProjectActionsUnavailableError({ projectId, cause })) + : Effect.fail(cause), + }), + Effect.asVoid, + ); + + return ProjectActions.of({ + list: Effect.fn("ProjectActions.list")(function* (projectId) { + const { project, scripts } = yield* read(projectId); + return { + projectId: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + actions: scripts, + }; + }), + upsert: Effect.fn("ProjectActions.upsert")(function* (input) { + const { project, scripts } = yield* read(input.projectId); + const result = yield* applyUpsert(scripts, input); + yield* write(project.id, scripts, result.scripts); + return { + projectId: project.id, + action: result.created ? "created" : "updated", + projectAction: result.projectAction, + clearedRunOnWorktreeCreate: result.clearedRunOnWorktreeCreate, + } as const; + }), + remove: Effect.fn("ProjectActions.remove")(function* (input) { + const { project, scripts } = yield* read(input.projectId); + const action = yield* findAction(project.id, scripts, input.actionId); + yield* write( + project.id, + scripts, + scripts.filter((candidate) => candidate.id !== action.id), + ); + return { projectId: project.id, action: "removed", actionId: action.id } as const; + }), + }); +}); + +export const layer = Layer.effect(ProjectActions, make); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index e3e78a83876a..0861ff63f69f 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -219,7 +219,8 @@ Archiving a thread that is still working schedules the archive for when it is do menus (and swipe-right on mobile), plus the command palette and the archive keybinding on web and desktop, offer **Archive when done** while a turn runs. On any client, send [`/t3-archive`](./composer.md#commands-and-skills) in the thread. An agent can also schedule -its own thread, for example when you ask it to "archive this thread when you're done". The +its own thread, for example when you ask it to "archive this thread when you're done", or +another thread, such as one whose results it has just read. The archive waits for the turn and its checkpoint, and for subagents or monitors it left running; background commands such as dev servers do not hold it and stop when the thread archives. An idle thread archives right away.