From 848e73d66d5cedd266b03d08fc892920058251c4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 13 Nov 2026 13:27:52 -0800 Subject: [PATCH 1/3] fix(server): agents find T3's pstack when a message names it T3 turns off native pstack plugins in the sessions it starts, but its own pack only reached the agent through an explicit $pstack:. A plain "use pstack principles" left the agent with no copy, so it asked where pstack was and read a stale Cursor cache instead. A user message that names pstack without invoking a skill now gets a short locator naming T3's pack. Turn start and steering share one predicate, so a mention mid-turn is covered too. No workflow or role resolution runs. Co-Authored-By: Claude Opus 5.5 --- .../ProviderTurnControlService.test.ts | 6 ++- .../ProviderTurnControlService.ts | 4 +- apps/server/src/pstack/PstackPack.test.ts | 7 ++++ apps/server/src/pstack/PstackPack.ts | 13 ++++-- apps/server/src/pstack/composer.ts | 40 ++++++++++++------- apps/server/src/pstack/pstackModels.ts | 10 +++++ docs/user/pstack.md | 1 + 7 files changed, 60 insertions(+), 21 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 6d390566234e4..dce53b2e756cb 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -604,7 +604,8 @@ it.effect.each([ messageId, }); }).pipe(Effect.provide(Layer.merge(layers, fixture.settingsLayer))); - assert.equal(threadReads, shouldExpand ? 1 : 0); + const locates = mode === "plain-name"; + assert.equal(threadReads, shouldExpand || locates ? 1 : 0); assert.lengthOf(received, 1); assert.include(received[0]!, "native:review"); if (mode === "pack-failure" || mode === "settings-failure") { @@ -617,6 +618,9 @@ it.effect.each([ else assert.notInclude(received[0]!, "Fallback:"); assert.include(received[0]!, ""); assert.notInclude(received[0]!, "$pstack:architect"); + } else if (locates) { + assert.include(received[0]!, "pstack is built into T3 Code."); + assert.notInclude(received[0]!, "Resolved roles"); } else { assert.notInclude(received[0]!, ""); if (mode !== "no-mention") assert.include(received[0]!, "pstack:architect"); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index 439250e110c1d..01c8360786658 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -1,5 +1,5 @@ import * as ServerSettings from "../serverSettings.ts"; -import { hasPstackComposerInvocation } from "../pstack/composer.ts"; +import { referencesPstack } from "../pstack/composer.ts"; import * as PstackPack from "../pstack/PstackPack.ts"; import { collectComposerContextReferences, @@ -473,7 +473,7 @@ export const layer: Layer.Layer< } let composer = { text: message.text, records: message.context?.records ?? [] }; if ( - hasPstackComposerInvocation(composer, message) && + referencesPstack(composer, message) && (yield* settings.getSettings.pipe( Effect.map((current) => current.pstackEnabled), // Expansion reports settings failures without preventing message delivery. diff --git a/apps/server/src/pstack/PstackPack.test.ts b/apps/server/src/pstack/PstackPack.test.ts index 66f311572b0cc..1cec9a4385f08 100644 --- a/apps/server/src/pstack/PstackPack.test.ts +++ b/apps/server/src/pstack/PstackPack.test.ts @@ -297,9 +297,16 @@ it.layer(NodeServices.layer)("PstackPack", (it) => { )).text, "$pstack:unknown", ); + // T3 turns native pstack plugins off, so a plain mention must still find T3's pack. + const mention = { ...input, text: "use pstack principles" }; + const located = (yield* pack.expandComposer(mention, { createdBy: "user" })).text; + assert.include(located, `: ${directory}`); + assert.notInclude(located, "Resolved roles"); + assert.strictEqual(yield* pack.expandComposer(mention, { createdBy: "agent" }), mention); yield* settings.updateSettings({ pstackEnabled: false }); assert.equal(yield* pack.catalog, undefined); assert.strictEqual(yield* pack.expandComposer(input, { createdBy: "user" }), input); + assert.strictEqual(yield* pack.expandComposer(mention, { createdBy: "user" }), mention); }).pipe(Effect.provide(Layer.merge(fixture.layer, fixture.settingsLayer))); }), ); diff --git a/apps/server/src/pstack/PstackPack.ts b/apps/server/src/pstack/PstackPack.ts index fb55b120cfb9d..7bc095ef80576 100644 --- a/apps/server/src/pstack/PstackPack.ts +++ b/apps/server/src/pstack/PstackPack.ts @@ -28,8 +28,13 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import { readOrchestrationProviders } from "../provider/orchestrationTargets.ts"; import { isProcessAlive } from "../serverRuntimeState.ts"; -import { planPstackComposer } from "./composer.ts"; -import { formatPstackBlock, parseModelsFile, resolveRoles } from "./pstackModels.ts"; +import { planPstackComposer, referencesPstack } from "./composer.ts"; +import { + formatPstackBlock, + formatPstackLocator, + parseModelsFile, + resolveRoles, +} from "./pstackModels.ts"; class PstackPackError extends Schema.TaggedError()("PstackPackError", { path: Schema.String, @@ -299,10 +304,12 @@ const make = Effect.fn("PstackPack.make")(function* (sourceRoot?: string) { )( function* (input, message) { const plan = planPstackComposer(input, message); - if (plan === undefined) return input; + if (plan === undefined && !referencesPstack(input, message)) return input; const currentSettings = yield* settings.getSettings; if (!currentSettings.pstackEnabled) return input; const pack = yield* readPack(); + if (plan === undefined) + return { ...input, text: `${input.text.trimEnd()}\n\n${formatPstackLocator(packDir)}` }; const names = new Set( plan.skillNames.filter((name) => pack.skills.some((skill) => skill.name === name)), ); diff --git a/apps/server/src/pstack/composer.ts b/apps/server/src/pstack/composer.ts index ec463cd9109ee..d5b803ead83b2 100644 --- a/apps/server/src/pstack/composer.ts +++ b/apps/server/src/pstack/composer.ts @@ -6,19 +6,21 @@ import { replaceComposerContextReferences, } from "@t3tools/shared/composerContextReferences"; -export function hasPstackComposerInvocation( +type ComposerMessage = Pick< + OrchestrationV2ConversationMessage, + "createdBy" | "notification" | "delegatedCompletion" +>; + +const isUserAuthored = (message: ComposerMessage) => + message.createdBy === "user" && + message.notification === undefined && + message.delegatedCompletion === undefined; + +function hasPstackComposerInvocation( input: { readonly text: string; readonly records: ReadonlyArray }, - message: Pick< - OrchestrationV2ConversationMessage, - "createdBy" | "notification" | "delegatedCompletion" - >, + message: ComposerMessage, ): boolean { - if ( - message.createdBy !== "user" || - message.notification !== undefined || - message.delegatedCompletion !== undefined - ) - return false; + if (!isUserAuthored(message)) return false; if ( input.records.some( (record) => @@ -38,10 +40,7 @@ export function planPstackComposer( readonly text: string; readonly records: ReadonlyArray; }, - message: Pick< - OrchestrationV2ConversationMessage, - "createdBy" | "notification" | "delegatedCompletion" - >, + message: ComposerMessage, ) { if (!hasPstackComposerInvocation(input, message)) return undefined; const mentions = [...input.text.matchAll(SKILL_MENTION_PATTERN)] @@ -98,3 +97,14 @@ export function planPstackComposer( }, }; } + +/** An invocation, or a user naming pstack without one, for example "use pstack principles". */ +export function referencesPstack( + input: { readonly text: string; readonly records: ReadonlyArray }, + message: ComposerMessage, +): boolean { + return ( + hasPstackComposerInvocation(input, message) || + (isUserAuthored(message) && /\bpstack\b/i.test(input.text)) + ); +} diff --git a/apps/server/src/pstack/pstackModels.ts b/apps/server/src/pstack/pstackModels.ts index d88f18ffa5a61..ef2ecb1e98a48 100644 --- a/apps/server/src/pstack/pstackModels.ts +++ b/apps/server/src/pstack/pstackModels.ts @@ -298,6 +298,16 @@ export function resolveRoles(input: { return { roles, diagnostics }; } +// Native pstack plugins are off in T3 sessions, so this is the agent's only pointer to the pack. +export function formatPstackLocator(packDir: string): string { + return [ + "", + `pstack is built into T3 Code. : ${packDir}`, + "Read /skills//SKILL.md for a pstack skill; its principles are the principle-* skills. Running a workflow with T3's model roles needs $pstack:.", + "", + ].join("\n"); +} + export function formatPstackBlock(input: { readonly skillNames: ReadonlyArray; readonly packDir: string; diff --git a/docs/user/pstack.md b/docs/user/pstack.md index a438e86f7d80a..3479dc2463810 100644 --- a/docs/user/pstack.md +++ b/docs/user/pstack.md @@ -30,6 +30,7 @@ Type `$` or `/` in the composer and pick a `pstack:` skill, for example `$pstack cache layer`. Start rigorous work with `$pstack:poteto-mode`; it picks the other skills as it goes. Helper agents appear under the thread as tasks you can open, follow, or stop. Use `$pstack:automate-maintainer` or `$pstack:automate-team` to capture a maintainer’s or team’s engineering conventions. +Mentioning pstack without a skill, such as “use pstack principles”, points the agent at T3 Code’s copy so it can read the skills, but runs no workflow. When a model becomes unavailable, pools use their remaining choices; if none remain, they use your thread’s model. Panels keep every seat, using your thread’s model for unavailable entries. The reply From 00e420671e3eb4c522bf65f82bb17ba3b9a8f3a5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 13 Nov 2026 13:45:03 -0800 Subject: [PATCH 2/3] feat: pstack is on by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T3 ships pstack built in, so new environments and settings that never chose now get it. An explicit off in Settings → Labs → Zack still wins. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/ws.test.ts | 2 +- docs/user/composer.md | 2 +- docs/user/pstack.md | 8 ++++---- packages/contracts/src/settings.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 7460d596931d8..f6ef7a94ababb 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1126,7 +1126,7 @@ it.effect.each([ assert.isEmpty(expectedSettings); assert.isEmpty(expectedKeybindings); const callsBeforeToggle = editorDiscoveryCalls; - for (const enabled of [true, false]) { + for (const enabled of [false, true]) { yield* settings.updateSettings({ pstackEnabled: enabled }); const event = yield* Queue.take(responses); assert.equal(event.type, "snapshot"); diff --git a/docs/user/composer.md b/docs/user/composer.md index ae85baab24ece..191e6de38c7a6 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -369,7 +369,7 @@ provider. On mobile, both are also available before starting a thread on The slash menu also includes skills unless you turn off **Settings → Chat → Show skills in slash menu**, under Advanced. Only skills enabled for the provider are listed. -Built-in [pstack](./pstack.md) skills, once turned on, are listed for every +Built-in [pstack](./pstack.md) skills, unless turned off, are listed for every provider. After you add or change skills, plugins, or MCP servers, use **Restart agent diff --git a/docs/user/pstack.md b/docs/user/pstack.md index 3479dc2463810..d087e51d84f46 100644 --- a/docs/user/pstack.md +++ b/docs/user/pstack.md @@ -6,10 +6,10 @@ and runs it through its orchestration tools across supported providers. Its mult one `$pstack:architect` can run an Opus runner and a GPT runner side by side, whichever provider your thread uses. -## Turn it on +## Turn it off -Open **Settings → Labs → Zack** and turn on **pstack**. The setting applies to one environment, so turn it on for each -machine you want it on. Turning it off removes the `pstack:` skills from the composer +pstack is on by default. To turn it off, open **Settings → Labs → Zack** and turn off **pstack**. The +setting applies to one environment. Turning it off removes the `pstack:` skills from the composer and keeps your model choices. ## Choose models once @@ -40,7 +40,7 @@ the current capabilities before choosing another target. ## Good to know -- pstack runs on the machine the thread runs on. A linked machine needs pstack turned on and its own +- pstack runs on the machine the thread runs on. A linked machine needs pstack on and its own `$pstack:setup-pstack`, since its providers differ. - OpenCode sessions using an external **Server URL** do not expose T3's orchestration tools. Leave that setting blank to let T3 manage the server before running a pstack workflow. diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 000b08f479017..3a1eefdfa3e50 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1496,7 +1496,7 @@ export const ServerSettings = Schema.Struct({ enableAgentDeviceAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), /** Offer the bundled pstack skills in every provider composer. */ - pstackEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + pstackEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), /** Off by default: turning it on moves skill folders out of the user's Claude and Codex homes. */ skillsLibraryEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), /** On by default: each settled run costs a text generation call unless its project opts out. */ From 76152261654160127c0e9083a230ba91e35394a6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 13 Nov 2026 14:30:13 -0800 Subject: [PATCH 3/3] fix(server): a /goal that names pstack keeps its objective Codex stores a goal's text as its objective, so the pstack locator became part of the goal and could push it past Codex's length limit. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/pstack/PstackPack.test.ts | 3 +++ apps/server/src/pstack/composer.ts | 11 +++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/server/src/pstack/PstackPack.test.ts b/apps/server/src/pstack/PstackPack.test.ts index 1cec9a4385f08..9e5b1e63ebc59 100644 --- a/apps/server/src/pstack/PstackPack.test.ts +++ b/apps/server/src/pstack/PstackPack.test.ts @@ -303,6 +303,9 @@ it.layer(NodeServices.layer)("PstackPack", (it) => { assert.include(located, `: ${directory}`); assert.notInclude(located, "Resolved roles"); assert.strictEqual(yield* pack.expandComposer(mention, { createdBy: "agent" }), mention); + // Codex stores a goal's text as its objective. + const goal = { ...input, text: "/goal use pstack principles" }; + assert.strictEqual(yield* pack.expandComposer(goal, { createdBy: "user" }), goal); yield* settings.updateSettings({ pstackEnabled: false }); assert.equal(yield* pack.catalog, undefined); assert.strictEqual(yield* pack.expandComposer(input, { createdBy: "user" }), input); diff --git a/apps/server/src/pstack/composer.ts b/apps/server/src/pstack/composer.ts index d5b803ead83b2..e61f3e26a8399 100644 --- a/apps/server/src/pstack/composer.ts +++ b/apps/server/src/pstack/composer.ts @@ -1,4 +1,8 @@ -import type { ComposerContextRecord, OrchestrationV2ConversationMessage } from "@t3tools/contracts"; +import { + type ComposerContextRecord, + type OrchestrationV2ConversationMessage, + parseGoalCommand, +} from "@t3tools/contracts"; import { PSTACK_SKILL_PREFIX } from "@t3tools/pstack"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { @@ -105,6 +109,9 @@ export function referencesPstack( ): boolean { return ( hasPstackComposerInvocation(input, message) || - (isUserAuthored(message) && /\bpstack\b/i.test(input.text)) + (isUserAuthored(message) && + /\bpstack\b/i.test(input.text) && + // A native goal's text becomes its objective, so it carries no locator. + parseGoalCommand(input) === null) ); }