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
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ const PAGE_TITLES: Record<SettingsPage, string> = {
const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettingKey[]> = {
"new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"],
"source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin"],
"agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"],
"agent-behavior": [
"responseStreamingMode",
"enableAgentBrowserAccess",
"enableAgentThreadControl",
],
maintenance: ["continueThreadsAfterServerUpdate"],
};

Expand Down Expand Up @@ -396,6 +400,16 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
onValueChange={(value) => write({ enableAgentBrowserAccess: value })}
/>
</SettingsSection>
<SettingsSection title="Threads">
<FanoutSwitchRow
icon="square.on.square"
label="Agent thread control"
subtitle="Allow agents to start, message, and wait on other threads."
value={uniform("enableAgentThreadControl")}
disabled={disabledFor("enableAgentThreadControl")}
onValueChange={(value) => write({ enableAgentThreadControl: value })}
/>
</SettingsSection>
</>
) : null}

Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import {
HttpServerResponse,
} from "effect/unstable/http";

import * as CheckpointDiffQuery from "../checkpointing/CheckpointDiffQuery.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as GitWorkflowService from "../git/GitWorkflowService.ts";
import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
import { ProviderService } from "../provider/Services/ProviderService.ts";
import { UsageService } from "../usage/UsageService.ts";
import * as ClientCommandDispatcher from "../orchestration/ClientCommandDispatcher.ts";
import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts";
import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts";
Expand Down Expand Up @@ -923,6 +928,11 @@ it.effect("lists thread-control tools only to credentials that may drive threads
Layer.mock(ProviderRegistry.ProviderRegistry)({}),
Layer.mock(ServerSettings.ServerSettingsService)({}),
Layer.mock(GitWorkflowService.GitWorkflowService)({}),
Layer.mock(CheckpointDiffQuery.CheckpointDiffQuery)({}),
Layer.mock(ProviderService)({}),
Layer.mock(ServerEnvironment.ServerEnvironment)({}),
Layer.mock(UsageService)({}),
SqlitePersistenceMemory,
NodeServices.layer,
),
),
Expand Down Expand Up @@ -974,8 +984,13 @@ it.effect("lists thread-control tools only to credentials that may drive threads
expect(yield* listToolNames("off")).toEqual([]);
expect(yield* listToolNames("on")).toEqual([
"create_thread",
"get_thread",
"interrupt_turn",
"list_messages",
"list_models",
"list_projects",
"list_threads",
"search",
"send_message",
"wait_for_thread",
]);
Expand Down
7 changes: 5 additions & 2 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
PreviewSnapshotToolkit,
PreviewStandardToolkit,
} from "./toolkits/preview/tools.ts";
import { ThreadReadToolkitHandlersLive } from "./query/handlers.ts";
import { ThreadReadToolkit } from "./query/tools.ts";
import { OperateToolkitHandlersLive } from "./toolkits/operate/handlers.ts";
import { OperateToolkit } from "./toolkits/operate/tools.ts";
import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts";
Expand Down Expand Up @@ -652,8 +654,9 @@ export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequest
);

/** Listed only to credentials that may drive threads; see McpActor.operateToolsVisible. */
export const OperateToolkitRegistrationLive = McpServer.toolkit(OperateToolkit).pipe(
Layer.provide(OperateToolkitHandlersLive),
export const OperateToolkitRegistrationLive = Layer.mergeAll(
McpServer.toolkit(OperateToolkit).pipe(Layer.provide(OperateToolkitHandlersLive)),
McpServer.toolkit(ThreadReadToolkit).pipe(Layer.provide(ThreadReadToolkitHandlersLive)),
);

const DeviceStandardToolkitRegistrationLive = McpServer.toolkit(DeviceStandardToolkit).pipe(
Expand Down
16 changes: 15 additions & 1 deletion apps/server/src/mcp/query/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
type PageRequest,
type Window,
} from "./store.ts";
import { QueryToolError, QueryToolkit } from "./tools.ts";
import { QueryToolError, QueryToolkit, ThreadReadToolkit } from "./tools.ts";

const GUIDE = [
"All tools are read-only and see what the user sees in T3 Code.",
Expand Down Expand Up @@ -443,3 +443,17 @@ const make = Effect.gen(function* () {
});

export const QueryToolkitHandlersLive = QueryToolkit.toLayer(make);

export const ThreadReadToolkitHandlersLive = ThreadReadToolkit.toLayer(
make.pipe(
Effect.map((handlers) =>
ThreadReadToolkit.of({
list_projects: handlers.list_projects,
list_threads: handlers.list_threads,
get_thread: handlers.get_thread,
list_messages: handlers.list_messages,
search: handlers.search,
}),
),
),
);
17 changes: 16 additions & 1 deletion apps/server/src/mcp/query/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
UsageSummaryInput,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import { Tool, Toolkit } from "effect/unstable/ai";
import { McpSchema, Tool, Toolkit } from "effect/unstable/ai";

import * as McpActor from "../McpActor.ts";

/**
* The read-only history toolkit served at `/mcp/query`. Every tool is a
Expand Down Expand Up @@ -784,3 +786,16 @@ export const QueryToolkit = Toolkit.make(
readOnly(GetPullRequestTool, "Get pull request"),
readOnly(GetUsageSummaryTool, "Get usage summary"),
);

/**
* The history tools an agent inside a thread gets with thread control: enough
* to find a project and read what other threads did. Listed only to
* credentials that may drive threads, like the operate tools.
*/
export const ThreadReadToolkit = Toolkit.make(
QueryToolkit.tools.list_projects.annotate(McpSchema.EnabledWhen, McpActor.operateToolsVisible),
QueryToolkit.tools.list_threads.annotate(McpSchema.EnabledWhen, McpActor.operateToolsVisible),
QueryToolkit.tools.get_thread.annotate(McpSchema.EnabledWhen, McpActor.operateToolsVisible),
QueryToolkit.tools.list_messages.annotate(McpSchema.EnabledWhen, McpActor.operateToolsVisible),
QueryToolkit.tools.search.annotate(McpSchema.EnabledWhen, McpActor.operateToolsVisible),
);
31 changes: 29 additions & 2 deletions apps/server/src/provider/Layers/ProviderService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5217,14 +5217,20 @@ describe("agent browser access", () => {
const projectId = ProjectId.make("project-browser-access");

const startSessionWith = (
access: boolean | { readonly browser: boolean; readonly device: boolean },
access:
| boolean
| { readonly browser: boolean; readonly device: boolean; readonly control?: boolean },
threadId: ThreadId,
projectOverride?: boolean | { readonly browser?: boolean; readonly device?: boolean },
projectOverride?:
| boolean
| { readonly browser?: boolean; readonly device?: boolean; readonly control?: boolean },
options?: { readonly withoutOrchestration?: boolean },
) =>
Effect.gen(function* () {
const enableAgentBrowserAccess = typeof access === "boolean" ? access : access.browser;
const enableAgentDeviceAccess = typeof access === "boolean" ? access : access.device;
const enableAgentThreadControl =
typeof access === "boolean" ? false : access.control === true;
const issued: Array<{ threadId: ThreadId; capabilities: ReadonlyArray<string> }> = [];
const codex = makeFakeCodexAdapter();
const providerAdapterLayer = Layer.succeed(
Expand Down Expand Up @@ -5302,6 +5308,7 @@ describe("agent browser access", () => {
ServerSettings.ServerSettingsService.layerTest({
enableAgentBrowserAccess,
enableAgentDeviceAccess,
enableAgentThreadControl,
projectSettingsOverrides:
projectOverride === undefined
? {}
Expand All @@ -5315,6 +5322,9 @@ describe("agent browser access", () => {
...(projectOverride.device !== undefined
? { enableAgentDeviceAccess: projectOverride.device }
: {}),
...(projectOverride.control !== undefined
? { enableAgentThreadControl: projectOverride.control }
: {}),
},
},
}),
Expand Down Expand Up @@ -5401,6 +5411,23 @@ describe("agent browser access", () => {
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("grants thread control only when it is turned on, and a project can turn it off", () =>
Effect.gen(function* () {
const on = asThreadId("thread-control-on");
assert.deepEqual(
yield* startSessionWith({ browser: false, device: false, control: true }, on),
[{ threadId: on, capabilities: ["orchestration", "pull-requests"] }],
);
const projectOff = asThreadId("thread-control-project-off");
assert.deepEqual(
yield* startSessionWith({ browser: false, device: false, control: true }, projectOff, {
control: false,
}),
[{ threadId: projectOff, capabilities: ["pull-requests"] }],
);
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("a project device override grants device access when the environment denies it", () =>
Effect.gen(function* () {
const threadId = asThreadId("thread-project-device-on");
Expand Down
15 changes: 11 additions & 4 deletions apps/server/src/provider/Layers/ProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -890,7 +890,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
yield* recordCompletedTurnProperties(properties);
});
/**
* Whether the credential minted below may drive the user's browser.
* Whether the credential minted below may drive the user's browser, devices, and threads.
*
* Deny on an unreadable settings file rather than letting the read failure
* escape: adding `ServerSettingsError` to `ProviderServiceError` would widen
Expand All @@ -907,17 +907,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
(entry) => entry.enableAgentBrowserAccess !== undefined,
);
const deviceOverridden = entries.some((entry) => entry.enableAgentDeviceAccess !== undefined);
const controlOverridden = entries.some(
(entry) => entry.enableAgentThreadControl !== undefined,
);
const environment = {
browser: settings.enableAgentBrowserAccess,
device: settings.enableAgentDeviceAccess,
control: settings.enableAgentThreadControl,
};
if (!browserOverridden && !deviceOverridden) return environment;
if (!browserOverridden && !deviceOverridden && !controlOverridden) return environment;
// Provider-only runtimes may omit orchestration. An unresolved project
// must not bypass an explicit project override, but a capability no
// project overrides keeps its environment value.
const denied = {
browser: browserOverridden ? false : environment.browser,
device: deviceOverridden ? false : environment.device,
control: controlOverridden ? false : environment.control,
};
if (Option.isNone(projectionQuery)) return denied;
const thread = yield* projectionQuery.value.getThreadShellById(threadId);
Expand All @@ -926,13 +931,14 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
return {
browser: resolved.enableAgentBrowserAccess,
device: resolved.enableAgentDeviceAccess,
control: resolved.enableAgentThreadControl,
};
},
Effect.catch((cause) =>
Effect.logWarning(
"Could not read server settings; withholding agent browser and device access for this session.",
"Could not read server settings; withholding agent browser, device, and thread control for this session.",
{ cause },
).pipe(Effect.as({ browser: false, device: false })),
).pipe(Effect.as({ browser: false, device: false, control: false })),
),
);

Expand All @@ -943,6 +949,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
const access = yield* agentAccessSettings(threadId);
if (access.browser) capabilities.add("preview");
if (access.device) capabilities.add("device");
if (access.control) capabilities.add("orchestration");
return capabilities;
});

Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/components/settings/ProjectDefaultsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]);
const mixedSubmodules = useScopedSettingsMixed(["worktreeSubmodules"]);
const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]);
const mixedThreadControl = useScopedSettingsMixed(["enableAgentThreadControl"]);
const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]);
const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]);
const modelSource = useScopedSettingSource(["defaultModelSelection"]);
Expand Down Expand Up @@ -496,6 +497,39 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting
/>
}
/>
<SettingsRow
serverScoped
settingKeys={["enableAgentThreadControl"]}
mixed={mixedThreadControl}
id={searchableSetting("agent-thread-control").id}
title="Agent thread control"
description={
isProjectScope
? "Allow agents in this project to start, message, and wait on other threads. Applies when the agent session next starts."
: "Allow agents to start, message, and wait on other threads. Projects can override it."
}
resetAction={
settings.enableAgentThreadControl !==
DEFAULT_SERVER_SETTINGS.enableAgentThreadControl ? (
<SettingResetButton
label="default thread control"
onClick={() =>
updateSettings({
enableAgentThreadControl: DEFAULT_SERVER_SETTINGS.enableAgentThreadControl,
})
}
/>
) : null
}
control={
<Switch
aria-label="Agent thread control"
mixed={mixedThreadControl}
checked={mixedThreadControl ? false : settings.enableAgentThreadControl}
onCheckedChange={(enabled) => updateSettings({ enableAgentThreadControl: enabled })}
/>
}
/>
</>
)}
</SettingsSection>
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,9 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess
? ["Agent browser access"]
: []),
...(settings.enableAgentThreadControl !== DEFAULT_UNIFIED_SETTINGS.enableAgentThreadControl
? ["Agent thread control"]
: []),
],
[
isTextGenerationModelDirty,
Expand All @@ -686,6 +689,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.diffColorScheme,
settings.chatWidth,
settings.enableAgentBrowserAccess,
settings.enableAgentThreadControl,
settings.confirmQuit,
settings.confirmThreadArchive,
settings.confirmThreadDelete,
Expand Down Expand Up @@ -869,6 +873,7 @@ export function useSettingsRestore(onRestored?: () => void) {
// name, so a user restoring defaults is told the agent regains access
// rather than discovering it later.
enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess,
enableAgentThreadControl: DEFAULT_UNIFIED_SETTINGS.enableAgentThreadControl,
});
onRestored?.();
}, [
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,15 @@ export const SETTINGS_SEARCH_ITEMS = [
scope: "project-defaults",
searchTerms: ["allow disable enable open drive preview tools sessions project override"],
},
{
id: "agent-thread-control",
title: "Agent thread control",
to: "/settings/integrations",
scope: "project-defaults",
searchTerms: [
"allow enable start spawn create message wait threads agents mcp project override",
],
},
{
id: "open-in",
title: "Open in applications and file links",
Expand Down
19 changes: 18 additions & 1 deletion docs/fork-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ A second MCP server at `/mcp/query` gives agents outside T3 Code read-only acces
environment's history: projects, threads, turns, messages, activities, plans, turn diffs, linked
pull requests, and usage. Its tools page with cursors, take `since`/`until` bounds, and cap text.
It authenticates with an environment bearer token that has `orchestration:read`, never a cookie,
and lists only its own tools. The thread-scoped `/mcp` server is unchanged.
and lists only its own tools.

Tokens come from **Settings → Connections → Agent access** (`POST /api/auth/agent-access-tokens`)
or `t3 auth session issue --read-only`. Each token is a normal client session, so revoking it works
Expand All @@ -635,6 +635,23 @@ Code: `apps/server/src/mcp/query/`, the `agentAccessToken` handler in
`apps/web/src/components/settings/AgentAccessSettings.tsx`. User guide:
[agent-access.md](./user/agent-access.md).

## Agents start and drive threads

With **Agent thread control** on (off by default; environment setting with project overrides), a
thread's `t3-code` MCP server also lists `create_thread`, `send_message`, `wait_for_thread`,
`interrupt_turn`, `list_models`, and the history tools `list_projects`, `list_threads`,
`get_thread`, `list_messages`, and `search`. Credentials without it never see them. A thread an
agent starts records `createdBy`. Its runtime mode cannot exceed the starting thread's, chains stop
two levels deep, a thread keeps at most five live children, and no tool answers another thread's
approvals or questions. Web, desktop, and mobile settings all carry the toggle.

Code: `apps/server/src/mcp/toolkits/operate/`, `apps/server/src/mcp/McpActor.ts`,
`ThreadReadToolkit` in `apps/server/src/mcp/query/tools.ts`, `agentAccessCapabilities` in
`apps/server/src/provider/Layers/ProviderService.ts`, `ThreadCreatedBy` in
`packages/contracts/src/orchestration.ts`, and
`apps/server/src/orchestration/ClientCommandDispatcher.ts`. User guide:
[agent-access.md](./user/agent-access.md#let-agents-start-threads).

## Attention inbox

On web and desktop, an inbox button appears in the sidebar header while any thread needs you, with
Expand Down
Loading