Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<environmentId>/<threadId>` 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
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
);
Expand All @@ -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),
);
Expand Down Expand Up @@ -822,6 +831,7 @@ const mcpToolkitIsland = <E, R>(path: `/${string}`, registrations: Layer.Layer<n
layerHtmlToolkitRegistration,
layerArchiveToolkitRegistration,
layerWorktreeSwitchToolkitRegistration,
layerProjectActionsToolkitRegistration,
).pipe(Layer.provideMerge(makeMcpTransport(path))),
);

Expand Down
87 changes: 87 additions & 0 deletions apps/server/src/mcp/toolkits/archive/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
CommandId,
EnvironmentId,
type OrchestrationV2ThreadShell,
ProjectId,
ProviderInstanceId,
RunId,
ThreadId,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
Expand Down Expand Up @@ -168,3 +170,88 @@ it.effect.each([
}).pipe(Effect.scoped, Effect.provide(makeLayer(owner, [], { shell, serviceCalls })));
},
);

// Targeting another thread: status reads any thread, schedule and cancel need
// the caller's live run and a target within the caller's modes.
const targetId = ThreadId.make("thread-archive-target");
const broadId = ThreadId.make("thread-archive-broad");

const makeTargetLayer = (calls: Array<string>, 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<string> = [];
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<string> = [];
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<string> = [];
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 })));
});
37 changes: 21 additions & 16 deletions apps/server/src/mcp/toolkits/archive/handlers.ts
Original file line number Diff line number Diff line change
@@ -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) => ({
Expand All @@ -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)));
}),
});
23 changes: 17 additions & 6 deletions apps/server/src/mcp/toolkits/archive/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
IsoDateTime,
OrchestrationV2ThreadArchiveRequest,
OrchestratorMcpFailure,
ThreadId,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import { Tool, Toolkit } from "effect/ai";
Expand All @@ -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),
Expand All @@ -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.",
}),
),
}),
Expand All @@ -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",
Expand All @@ -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",
Expand Down
Loading