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
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"playwright-core": "1.60.0",
"proper-lockfile": "4.1.2",
"saxes": "6.0.0",
"smol-toml": "1.8.0",
"stream-chain": "^4.2.5",
"stream-json": "3.6.0",
"undici": "catalog:",
Expand Down
91 changes: 68 additions & 23 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import * as PortScanner from "../../preview/PortScanner.ts";
import { attachmentRelativePath } from "../../attachmentStore.ts";
import { WorkflowAgentTranscriptReadObserver } from "../workflowAgentTranscript.ts";
import * as ServerConfig from "../../config.ts";
import * as ServerSettings from "../../serverSettings.ts";
import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
import {
T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH,
Expand Down Expand Up @@ -1202,31 +1203,75 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => {
);

// regression: #15073 the SDK replaced the --settings launch argument with T3's settings
it.effect.each([
`--settings '{"skillOverrides":{"lint":"off"},"showThinkingSummaries":false}'`,
"--settings user-settings.json",
])("merges %s beneath T3's settings", (launchArgs) =>
Effect.scoped(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-settings-" });
yield* fileSystem.writeFileString(
NodePath.join(cwd, "user-settings.json"),
'{"skillOverrides":{"lint":"off"},"showThinkingSummaries":false}',
);
const { exit, opened } = yield* startTurnWithLaunchArgs(launchArgs, cwd);
assert.isTrue(Exit.isSuccess(exit));
assert.deepInclude(opened[0]!.settings, {
skillOverrides: { lint: "off" },
showThinkingSummaries: true,
});
assert.isUndefined(opened[0]!.extraArgs?.["settings"]);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
it.effect.each(["inline JSON", "a file"])(
"merges --settings %s beneath T3's settings",
(source) =>
Effect.scoped(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-settings-" });
const launchSettings = {
skillOverrides: { lint: "off" },
showThinkingSummaries: false,
enabledPlugins: {
"pstack@launch": true,
"pstack@launch-array": ["^1.0.0"],
"pstack@launch-object": { version: "^1.0.0" },
"pstack@disabled": false,
"tracedecay@local": true,
},
};
const launchArgs =
source === "inline JSON"
? `--settings '${JSON.stringify(launchSettings)}'`
: "--settings user-settings.json";
yield* fileSystem.writeFileString(
NodePath.join(cwd, "user-settings.json"),
JSON.stringify(launchSettings),
);
yield* fileSystem.makeDirectory(NodePath.join(cwd, ".claude"));
yield* fileSystem.writeFileString(
NodePath.join(cwd, ".claude", "settings.json"),
JSON.stringify({
enabledPlugins: {
"pstack@settings": true,
"pstack@settings-array": ["^1.0.0"],
"pstack@settings-object": { version: "^1.0.0" },
"pstack@disabled": true,
},
}),
);
for (const pstackEnabled of [false, true]) {
const { exit, opened } = yield* startTurnWithLaunchArgs(launchArgs, cwd).pipe(
Effect.provide(ServerSettings.layerTest({ pstackEnabled })),
);
assert.isTrue(Exit.isSuccess(exit));
assert.deepInclude(opened[0]!.settings, {
skillOverrides: { lint: "off" },
showThinkingSummaries: true,
enabledPlugins: {
"pstack@launch": !pstackEnabled,
"pstack@launch-array": pstackEnabled ? false : ["^1.0.0"],
"pstack@launch-object": pstackEnabled ? false : { version: "^1.0.0" },
"pstack@disabled": false,
"tracedecay@local": true,
...(pstackEnabled
? {
"pstack@settings": false,
"pstack@settings-array": false,
"pstack@settings-object": false,
}
: {}),
},
});
assert.isUndefined(opened[0]!.extraArgs?.["settings"]);
}
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
),
),
);

// regression: #20413 malformed append-system-prompt launch args fail before a query opens
Expand Down
39 changes: 36 additions & 3 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ import {
import {
claudeLaunchArgDirectories,
discoverClaudeSkills,
readClaudeEnabledPlugins,
} from "../../provider/Drivers/ClaudeSkills.ts";
import { claudePstackPluginOverrides } from "../../pstack/nativePlugins.ts";
import {
compileClaudeModelSelection,
type CompiledClaudeModelSelection,
Expand Down Expand Up @@ -10366,6 +10368,39 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
Effect.orElseSucceed(() => true),
)
: true;
// T3's pstack pack replaces a native install; two copies register conflicting skills.
const pstackPluginOverrides =
Option.isSome(serverSettings) &&
(yield* serverSettings.value.getSettings.pipe(
Effect.map((settings) => settings.pstackEnabled),
Effect.orElseSucceed(() => false),
))
? claudePstackPluginOverrides(
yield* readClaudeEnabledPlugins(
adapterOptions.settings,
turnInput.runtimePolicy.cwd ?? undefined,
adapterOptions.environment,
).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
),
launchArgSettings?.enabledPlugins,
)
: undefined;
const launchSdkSettings =
launchArgSettings === undefined
? undefined
: claudeSdkLaunchArgSettings(launchArgSettings);
const sdkSettings =
pstackPluginOverrides === undefined
? launchSdkSettings
: ({
...launchSdkSettings,
enabledPlugins: {
...launchSdkSettings?.enabledPlugins,
...pstackPluginOverrides,
},
} as ClaudeSdkSettings);
const queryOptions = makeClaudeQueryOptions({
platform,
modelSelection: turnInput.modelSelection,
Expand All @@ -10375,9 +10410,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
cwd: turnInput.runtimePolicy.cwd,
attachmentsDir,
settings: adapterOptions.settings,
...(launchArgSettings === undefined
? {}
: { sdkSettings: claudeSdkLaunchArgSettings(launchArgSettings) }),
...(sdkSettings === undefined ? {} : { sdkSettings }),
environment: claudeQueryEnvironment(
adapterOptions.environment,
turnInput.appThread,
Expand Down
26 changes: 23 additions & 3 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ import {
} from "../../provider/EventNdjsonLogger.ts";
import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
import { codexAppServerArgs, resolveCodexLaunchArgs } from "../../provider/codexLaunchArgs.ts";
import { codexPstackPluginOverrideArgs } from "../../pstack/nativePlugins.ts";
import * as ServerSettings from "../../serverSettings.ts";
import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
Expand Down Expand Up @@ -1524,6 +1526,20 @@ export const layerAppServerClientFactory: Layer.Layer<
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers;
const agentScope = yield* AgentScope;
const serverSettings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService);
const fileSystem = yield* Effect.serviceOption(FileSystem.FileSystem);
const pstackPluginOverrideArgs = Effect.fn("CodexAdapterV2.pstackPluginOverrideArgs")(
function* (codexHome: string | undefined) {
if (Option.isNone(serverSettings) || Option.isNone(fileSystem)) return [];
const settings = yield* serverSettings.value.getSettings;
if (!settings.pstackEnabled) return [];
const home = codexHome ?? `${yield* HostProcess.HomeDirectory}/.codex`;
return codexPstackPluginOverrideArgs(
yield* fileSystem.value.readFileString(`${home}/config.toml`),
);
},
Effect.orElseSucceed((): ReadonlyArray<string> => []),
);

return CodexAppServerClientFactory.of({
open: Effect.fn("CodexAdapterV2.open")(function* (input) {
Expand All @@ -1534,9 +1550,13 @@ export const layerAppServerClientFactory: Layer.Layer<
};
const command = yield* makeCodexAppServerSpawnCommand({
command: input.settings.binaryPath || "codex",
args: codexAppServerArgs(
resolveCodexLaunchArgs(input.settings.launchArgs, input.environment),
),
args: [
...codexAppServerArgs(
resolveCodexLaunchArgs(input.settings.launchArgs, input.environment),
),
// T3's pstack pack replaces a native install; two copies register conflicting skills.
...(yield* pstackPluginOverrideArgs(environment.CODEX_HOME)),
],
env: environment,
threadId: input.threadId,
}).pipe(Effect.provideService(AgentScope, agentScope));
Expand Down
22 changes: 14 additions & 8 deletions apps/server/src/provider/Drivers/ClaudeSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ const readSkillSettings = Effect.fn("readSkillSettings")(function* (
const path = yield* Path.Path;
const platform = yield* HostProcess.Platform;
const overridesByName = new Map<string, SkillOverride>();
const enabledPlugins = new Map<string, boolean>();
const enabledPlugins = new Map<string, unknown>();
const repositoryRoot = cwd === undefined ? undefined : yield* findRepositoryRoot(cwd);

for (const settingsPath of claudeSettingsPaths(
Expand Down Expand Up @@ -192,12 +192,7 @@ const readSkillSettings = Effect.fn("readSkillSettings")(function* (
overridesByName.set(name, parseSkillOverride(value));
}
for (const [key, enabled] of Object.entries(parsed?.enabledPlugins ?? {})) {
if (typeof enabled === "boolean") {
enabledPlugins.set(key, enabled);
} else {
// Extended formats replace lower-precedence values without enabling the plugin here.
enabledPlugins.delete(key);
}
enabledPlugins.set(key, enabled);
}
}

Expand Down Expand Up @@ -240,7 +235,7 @@ function rebaseClaudePluginPath(path: Path.Path, pluginsPath: string, installPat
const discoverPluginSkillRoots = Effect.fn("discoverPluginSkillRoots")(function* (
configDirPath: string,
cwd: string | undefined,
enabledPlugins: ReadonlyMap<string, boolean>,
enabledPlugins: ReadonlyMap<string, unknown>,
environment: NodeJS.ProcessEnv,
) {
const fileSystem = yield* FileSystem.FileSystem;
Expand All @@ -267,6 +262,7 @@ const discoverPluginSkillRoots = Effect.fn("discoverPluginSkillRoots")(function*
workspace === undefined ? undefined : yield* findPluginRepositoryRoot(workspace);

for (const [key, entries] of Object.entries(installed.plugins).sort()) {
// Version-constrained entries are left to the native plugin resolver.
if (enabledPlugins.get(key) !== true) continue;
const applicable = yield* Effect.filter(
entries
Expand Down Expand Up @@ -417,6 +413,16 @@ export function claudeLaunchArgDirectories(launchArgs?: string): ReadonlyArray<s
return directories;
}

/** Plugin enablement after Claude's settings-file precedence, keyed `name@marketplace`. */
export const readClaudeEnabledPlugins = Effect.fn("readClaudeEnabledPlugins")(function* (
config: Pick<ClaudeSettings, "homePath">,
cwd: string | undefined,
environment: NodeJS.ProcessEnv,
): Effect.fn.Return<ReadonlyMap<string, unknown>, never, FileSystem.FileSystem | Path.Path> {
const configDirPath = yield* resolveClaudeConfigDirPath(config, environment, cwd);
return (yield* readSkillSettings(configDirPath, cwd, environment)).enabledPlugins;
});

/** First name wins, matching Claude Code's precedence of user skills over project skills. */
export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* (
config: Pick<ClaudeSettings, "homePath"> & Partial<Pick<ClaudeSettings, "launchArgs">>,
Expand Down
53 changes: 53 additions & 0 deletions apps/server/src/pstack/nativePlugins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "@effect/vitest";

import { claudePstackPluginOverrides, codexPstackPluginOverrideArgs } from "./nativePlugins.ts";

describe("native pstack plugins", () => {
it("disables every addressable Codex pstack plugin and nothing else", () => {
const config = [
'[plugins."pstack@pstack-codex"]',
"enabled = true",
' [plugins."pstack@other"]',
"enabled = false",
'[plugins."pstack-extra@local"]',
'[plugins."tracedecay@local"]',
'[plugins."pstack@dotted.market"]',
"[marketplaces.pstack-codex]",
].join("\n");
expect(codexPstackPluginOverrideArgs(config)).toEqual([
"-c",
"plugins.pstack@pstack-codex.enabled=false",
"-c",
"plugins.pstack@other.enabled=false",
]);
expect(codexPstackPluginOverrideArgs('[plugins."tracedecay@local"]\n')).toEqual([]);
for (const config of [
"[plugins.'pstack@local']\nenabled = true\n",
'plugins."pstack@local".enabled = true\n',
'[plugins]\n"pstack@local" = { enabled = true }\n',
'plugins = { "pstack@local" = { enabled = true } }\n',
]) {
expect(codexPstackPluginOverrideArgs(config)).toEqual([
"-c",
"plugins.pstack@local.enabled=false",
]);
}
expect(
codexPstackPluginOverrideArgs('instructions = """\n[plugins."pstack@fake"]\n"""\n'),
).toEqual([]);
expect(codexPstackPluginOverrideArgs('[plugins."pstack@broken"')).toEqual([]);
});

it("disables only Claude pstack plugins that settings enable", () => {
expect(
claudePstackPluginOverrides(
new Map([
["pstack@cursor-plugins", true],
["pstack@off", false],
["tracedecay@local", true],
]),
),
).toEqual({ "pstack@cursor-plugins": false });
expect(claudePstackPluginOverrides(new Map([["tracedecay@local", true]]))).toBeUndefined();
});
});
34 changes: 34 additions & 0 deletions apps/server/src/pstack/nativePlugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { parse } from "smol-toml";

// A harness-native install (`pstack@<marketplace>`) that T3's pack replaces.
const isNativePstackPluginKey = (key: string) => key.startsWith("pstack@");

/** Codex app-server `-c` overrides that switch off the pstack plugins a config.toml declares. */
export function codexPstackPluginOverrideArgs(configToml: string): ReadonlyArray<string> {
let plugins: unknown;
try {
plugins = parse(configToml).plugins;
} catch {
return [];
}
if (typeof plugins !== "object" || plugins === null || Array.isArray(plugins)) return [];
return (
Object.keys(plugins)
// Codex splits -c keys on every dot and keeps quotes, so only dot-free keys are addressable.
.filter((key) => isNativePstackPluginKey(key) && !key.includes("."))
.flatMap((key) => ["-c", `plugins.${key}.enabled=false`])
);
}

/** Claude flag-settings `enabledPlugins` entries that switch off enabled pstack plugins. */
export function claudePstackPluginOverrides(
enabledPlugins: ReadonlyMap<string, unknown>,
launchPlugins: Readonly<Record<string, unknown>> = {},
): Record<string, false> | undefined {
const merged = new Map<string, unknown>([...enabledPlugins, ...Object.entries(launchPlugins)]);
const keys = [...merged]
// Claude also enables plugins through version-constrained arrays and objects.
.filter(([key, enabled]) => enabled !== false && isNativePstackPluginKey(key))
.map(([key]) => key);
return keys.length === 0 ? undefined : Object.fromEntries(keys.map((key) => [key, false]));
}
6 changes: 4 additions & 2 deletions docs/user/pstack.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,7 @@ the current capabilities before choosing another target.
no longer need from the scheduled tasks list.
- Steps that need a Cursor-only feature, such as Bugbot setup or a Cursor cloud machine, are skipped
with a one-line note in the reply.
- If you also installed pstack directly in Claude Code or Cursor, the T3 Code `pstack:` skills
replace those in the composer. Only the T3 Code skills can mix providers.
- If you also installed pstack as a Codex or Claude Code plugin, T3 Code turns that plugin off in
the sessions it starts while pstack is on, so agents see one copy. Your own Codex and Claude Code
sessions outside T3 Code keep it. A Cursor install is only replaced in the composer. Only the
T3 Code skills can mix providers.
Loading
Loading