From d7a6ee1c0b5c8ab90e2fba218cfe9a13d6fe3fd0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 30 Sep 2026 23:01:20 -0700 Subject: [PATCH 1/2] feat(web): restart the agent session from cmd+k to load new skills and plugins Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Layers/AntigravityProvider.test.ts | 12 +++ .../provider/Layers/AntigravityProvider.ts | 19 +++- .../provider/Layers/ProviderRegistry.test.ts | 21 ++++- .../src/provider/Layers/ProviderRegistry.ts | 91 +++++++++++++------ .../src/provider/Services/ProviderRegistry.ts | 7 ++ apps/server/src/ws.ts | 1 + apps/web/src/components/CommandPalette.tsx | 52 +++++++++++ docs/user/composer.md | 4 + packages/client-runtime/src/state/server.ts | 1 + packages/contracts/src/rpc.ts | 3 + 10 files changed, 182 insertions(+), 29 deletions(-) diff --git a/apps/server/src/provider/Layers/AntigravityProvider.test.ts b/apps/server/src/provider/Layers/AntigravityProvider.test.ts index 34fd81a424ca..358dc78e8b02 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.test.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.test.ts @@ -620,6 +620,18 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => { after.workspaceSnapshots?.find((entry) => entry.cwd === "/workspace")?.skills, ).toEqual(skills); expect((yield* harness.provider.snapshotForCwd("/workspace")).skills).toEqual(skills); + + const rescanned = [ + ...skills, + { name: "review", path: "/workspace/.agent/skills/review", enabled: true }, + ]; + yield* harness.provider.snapshotForCwd("/workspace", rescanned); + yield* harness.provider.onSessionStarted(started, "/workspace"); + expect( + (yield* harness.provider.snapshot.getSnapshot).workspaceSnapshots?.find( + (entry) => entry.cwd === "/workspace", + )?.skills, + ).toEqual(rescanned); }), ), ); diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts index 956a5d81d3f4..c8ce9a052be1 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -377,7 +377,24 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func cwd: string, skills?: ServerProvider["skills"], ) { - if (skills) discoveredSkills.set(cwd, skills); + if (skills) { + discoveredSkills.set(cwd, skills); + // A rescan replaces the stored entry's skills. Session callbacks and + // health checks republish that entry, so it must not keep old ones. + yield* SubscriptionRef.update(metadata, (state) => + state.draft.workspaceSnapshots?.some((entry) => entry.cwd === cwd) + ? { + ...state, + draft: { + ...state.draft, + workspaceSnapshots: state.draft.workspaceSnapshots.map((entry) => + entry.cwd === cwd ? { ...entry, skills } : entry, + ), + }, + } + : state, + ); + } const snapshot = yield* getSnapshot; const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd); const resolvedSkills = skills ?? workspace?.skills ?? discoveredSkills.get(cwd) ?? []; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9022890b2e3d..31112942df85 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1479,6 +1479,8 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test slashCommands: [], } as const satisfies ServerProvider; const snapshotCalls = yield* Ref.make(0); + const scopedResult = yield* Ref.make(scopedProvider); + const cacheInvalidations = yield* Ref.make(0); const returnPendingSnapshot = yield* Ref.make(true); const probeStarted = yield* Deferred.make(); const releaseProbe = yield* Deferred.make(); @@ -1508,6 +1510,7 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test applyUsageLimits: () => Effect.void, }, snapshotForCwd, + invalidateCaches: Ref.update(cacheInvalidations, (count) => count + 1), adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], }); @@ -1517,7 +1520,7 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider; yield* Deferred.succeed(probeStarted, undefined); yield* Deferred.await(releaseProbe); - return scopedProvider; + return yield* Ref.get(scopedResult); }), ); const rebuiltProvider = { @@ -1593,6 +1596,22 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test ); yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); assert.strictEqual(yield* Ref.get(snapshotCalls), 2); + const newSkills = [ + ...scopedProvider.skills, + { name: "added", path: "/workspace/added/SKILL.md", enabled: true }, + ]; + yield* Ref.set(scopedResult, { ...scopedProvider, skills: newSkills }); + yield* registry.refreshWorkspaceSnapshot({ + instanceId, + cwd: "/workspace", + fresh: true, + }); + assert.strictEqual(yield* Ref.get(snapshotCalls), 3); + assert.strictEqual(yield* Ref.get(cacheInvalidations), 1); + assert.deepStrictEqual( + (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), + [newSkills], + ); yield* Ref.set(instancesRef, [rebuiltInstance]); yield* PubSub.publish(registryChanges, undefined); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a65bc66edf32..a2dc8e21a394 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -82,6 +82,15 @@ const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16; +function dropProviderWorkspaceSnapshot(provider: ServerProvider, cwd: string): ServerProvider { + return provider.workspaceSnapshots?.some((snapshot) => snapshot.cwd === cwd) + ? { + ...provider, + workspaceSnapshots: provider.workspaceSnapshots.filter((snapshot) => snapshot.cwd !== cwd), + } + : provider; +} + export function upsertProviderWorkspaceSnapshot( provider: ServerProvider, cwd: string, @@ -835,17 +844,43 @@ export const ProviderRegistryLive = Layer.effect( return yield* Ref.get(providersRef); }); + const updateProviders = ( + update: (providers: ReadonlyArray) => ReadonlyArray, + ) => + Ref.modify(providersRef, (currentProviders) => { + const nextProviders = update(currentProviders); + return [[currentProviders, nextProviders] as const, nextProviders]; + }).pipe( + Effect.tap(([previousProviders, nextProviders]) => + haveProvidersChanged(previousProviders, nextProviders) + ? PubSub.publish(changesPubSub, nextProviders) + : Effect.void, + ), + Effect.map(([, nextProviders]) => nextProviders), + ); + const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { readonly instanceId: ProviderInstanceId; readonly cwd: string; + readonly fresh?: boolean; }) { + // Fresh scans drop other instances' snapshots for this cwd first, so a + // composer on one of them scans again on next use, even when this + // instance is gone or cannot be scanned. + if (input.fresh) { + yield* updateProviders((providers) => + providers.map((candidate) => + candidate.instanceId === input.instanceId + ? candidate + : dropProviderWorkspaceSnapshot(candidate, input.cwd), + ), + ); + } const providers = yield* Ref.get(providersRef); const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); - if ( - !provider || - !provider.enabled || - provider.workspaceSnapshots?.some((s) => s.cwd === input.cwd) - ) { + const hasSnapshot = (candidate: ServerProvider) => + !input.fresh && candidate.workspaceSnapshots?.some((s) => s.cwd === input.cwd); + if (!provider || !provider.enabled || hasSnapshot(provider)) { return providers; } const instance = yield* instanceRegistry.getInstance(input.instanceId); @@ -857,42 +892,44 @@ export const ProviderRegistryLive = Layer.effect( next.set(instance, new Set(current).add(input.cwd)); return [true, next] as const; }); - if (!claimed) return yield* Ref.get(providersRef); - return yield* instance.snapshotForCwd(input.cwd).pipe( + // A fresh scan never joins a running one, which may predate the change. + if (!claimed && !input.fresh) return yield* Ref.get(providersRef); + // Fresh scans also re-read the machine snapshot: Claude's plugin + // commands come from it, not from the cwd scan. + const refreshMachineSnapshot = input.fresh + ? (instance.invalidateCaches ?? Effect.void).pipe( + Effect.andThen(refreshInstance(input.instanceId)), + ) + : Effect.void; + return yield* refreshMachineSnapshot.pipe( + Effect.andThen(instance.snapshotForCwd(input.cwd)), Effect.flatMap((scopedSnapshot) => scopedSnapshot.status === "error" ? Ref.get(providersRef) : instanceRegistry.getInstance(input.instanceId).pipe( Effect.flatMap((currentInstance) => { if (currentInstance !== instance) return Ref.get(providersRef); - return Ref.modify(providersRef, (currentProviders) => { - const nextProviders = currentProviders.map((candidate) => - candidate.instanceId === input.instanceId && - !candidate.workspaceSnapshots?.some((s) => s.cwd === input.cwd) + return updateProviders((currentProviders) => + currentProviders.map((candidate) => + candidate.instanceId === input.instanceId && !hasSnapshot(candidate) ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) : candidate, - ); - return [[currentProviders, nextProviders] as const, nextProviders]; - }).pipe( - Effect.tap(([previousProviders, nextProviders]) => - haveProvidersChanged(previousProviders, nextProviders) - ? PubSub.publish(changesPubSub, nextProviders) - : Effect.void, ), - Effect.map(([, nextProviders]) => nextProviders), ); }), ), ), Effect.ensuring( - Ref.update(workspaceRefreshesRef, (refreshes) => { - const next = new Map(refreshes); - const current = new Set(next.get(instance)); - current.delete(input.cwd); - if (current.size) next.set(instance, current); - else next.delete(instance); - return next; - }), + claimed + ? Ref.update(workspaceRefreshesRef, (refreshes) => { + const next = new Map(refreshes); + const current = new Set(next.get(instance)); + current.delete(input.cwd); + if (current.size) next.set(instance, current); + else next.delete(instance); + return next; + }) + : Effect.void, ), ); }); diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index bd917834e972..94edf8556c8b 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -48,9 +48,16 @@ export interface ProviderRegistryShape { instanceId: ProviderInstanceId, ) => Effect.Effect>; + /** + * Fill the skills and slash commands snapshot for one cwd. A cwd that + * already has a snapshot is left alone unless `fresh` is set. A fresh scan + * also refreshes the instance's machine snapshot and drops other + * instances' snapshots for the cwd. + */ readonly refreshWorkspaceSnapshot: (input: { readonly instanceId: ProviderInstanceId; readonly cwd: string; + readonly fresh?: boolean; }) => Effect.Effect>; /** diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 65923773907c..1bdf80208e55 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2612,6 +2612,7 @@ const makeWsRpcLayer = ( ? providerRegistry.refreshWorkspaceSnapshot({ instanceId: input.instanceId, cwd: input.cwd, + fresh: input.fresh === true, }) : input.instanceId !== undefined ? providerRegistry.refreshInstance(input.instanceId) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b08ecdeb3999..6ea939d666bb 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -53,6 +53,7 @@ import { MonitorIcon, MoonIcon, PaletteIcon, + RotateCcwIcon, SettingsIcon, SquarePenIcon, SunIcon, @@ -93,6 +94,8 @@ import { desktopLocalBackendId } from "../connection/desktopLocal"; import { filesystemEnvironment } from "../state/filesystem"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; +import { serverEnvironment } from "../state/server"; +import { threadEnvironment } from "../state/threads"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; @@ -725,6 +728,12 @@ function OpenCommandPaletteDialog(props: { const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { reportFailure: false, }); + const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, { + reportFailure: false, + }); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -1871,6 +1880,49 @@ function OpenCommandPaletteDialog(props: { } } + if (activeThread !== null) { + const thread = activeThread; + actionItems.push({ + kind: "action", + value: "action:restart-agent-session", + searchTerms: ["restart", "reset", "reload", "agent", "session", "skills", "plugins", "mcp"], + title: "Restart agent session", + icon: , + // Stopping the provider process keeps the conversation: the next message + // spawns a fresh one that resumes it and reloads skills, plugins, and MCP + // servers. The fresh workspace scan updates the composer's slash menu. + // Failures throw into executeItem's error toast. + run: async () => { + const { environmentId } = thread; + if (thread.session && thread.session.status !== "stopped") { + const stopped = await stopThreadSession({ + environmentId, + input: { threadId: thread.id }, + }); + if (stopped._tag === "Failure") throw squashAtomCommandFailure(stopped); + } + // The server stops the process after accepting the command. A failed + // stop shows in the thread. + toastManager.add({ + type: "success", + title: "Agent session will restart", + description: "Your next message starts a fresh session.", + }); + const project = projectByKey.get(`${environmentId}:${thread.projectId}`); + if (!project) return; + const refreshed = await refreshProviders({ + environmentId, + input: { + instanceId: thread.session?.providerInstanceId ?? thread.modelSelection.instanceId, + cwd: thread.worktreePath ?? project.workspaceRoot, + fresh: true, + }, + }); + if (refreshed._tag === "Failure") throw squashAtomCommandFailure(refreshed); + }, + }); + } + actionItems.push({ kind: "action", value: "action:open-file-picker", diff --git a/docs/user/composer.md b/docs/user/composer.md index 7ee8edcbb121..b9f90cd1d375 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -152,6 +152,10 @@ provider. On mobile, both are also available before starting a thread on The slash menu also includes skills unless you turn off **Settings → General → Show skills in slash menu**. Only skills enabled for the provider are listed. +After you add or change skills, plugins, or MCP servers, use **Restart agent +session** in the command palette on web and desktop. The conversation continues, +and your next message starts the agent again with the new setup. + Provider commands must start the message to run. T3 Code commands such as `/model` and `/plan`, and skill mentions, work on any line. diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 07ac64ebf69d..9a30986341cc 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1103,6 +1103,7 @@ export function createServerEnvironmentAtoms( environmentId, input.instanceId ?? null, input.cwd ?? null, + input.fresh ?? false, input.refreshModels ?? false, ]), }, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 215f3777a175..41331f0c46cf 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -494,6 +494,9 @@ const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, */ instanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), + /** With `instanceId` and `cwd`: rescan the workspace's skills and slash + * commands even when a snapshot for that cwd already exists. */ + fresh: Schema.optional(Schema.Boolean), /** Explicit user request: bypass T3-owned caches and rediscover models. * Background status refreshes must not open agent sessions. */ refreshModels: Schema.optional(Schema.Boolean), From db5e0bd1b8ba417ea054d75a612cc2768c013d44 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 30 Sep 2026 23:06:12 -0700 Subject: [PATCH 2/2] fix(server): a slow fresh scan no longer overwrites a newer workspace snapshot Co-Authored-By: Claude Opus 5.5 (1M context) --- .../provider/Layers/ProviderRegistry.test.ts | 39 ++++++++++++++++++- .../src/provider/Layers/ProviderRegistry.ts | 12 ++++-- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 31112942df85..0970ea88748b 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1481,6 +1481,10 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test const snapshotCalls = yield* Ref.make(0); const scopedResult = yield* Ref.make(scopedProvider); const cacheInvalidations = yield* Ref.make(0); + const scanGate = yield* Ref.make<{ + readonly started: Deferred.Deferred; + readonly release: Deferred.Deferred; + } | null>(null); const returnPendingSnapshot = yield* Ref.make(true); const probeStarted = yield* Deferred.make(); const releaseProbe = yield* Deferred.make(); @@ -1520,7 +1524,13 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider; yield* Deferred.succeed(probeStarted, undefined); yield* Deferred.await(releaseProbe); - return yield* Ref.get(scopedResult); + const result = yield* Ref.get(scopedResult); + const gate = yield* Ref.getAndSet(scanGate, null); + if (gate) { + yield* Deferred.succeed(gate.started, undefined); + yield* Deferred.await(gate.release); + } + return result; }), ); const rebuiltProvider = { @@ -1613,6 +1623,33 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test [newSkills], ); + // A slow fresh scan that read older files must not overwrite a + // newer scan that finished first. + const slowStarted = yield* Deferred.make(); + const releaseSlow = yield* Deferred.make(); + yield* Ref.set(scanGate, { started: slowStarted, release: releaseSlow }); + yield* Ref.set(scopedResult, scopedProvider); + const slowScan = yield* registry + .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace", fresh: true }) + .pipe(Effect.forkChild); + yield* Deferred.await(slowStarted); + const latestSkills = [ + ...newSkills, + { name: "latest", path: "/workspace/latest/SKILL.md", enabled: true }, + ]; + yield* Ref.set(scopedResult, { ...scopedProvider, skills: latestSkills }); + yield* registry.refreshWorkspaceSnapshot({ + instanceId, + cwd: "/workspace", + fresh: true, + }); + yield* Deferred.succeed(releaseSlow, undefined); + yield* Fiber.join(slowScan); + assert.deepStrictEqual( + (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills), + [latestSkills], + ); + yield* Ref.set(instancesRef, [rebuiltInstance]); yield* PubSub.publish(registryChanges, undefined); let rebuilt = yield* registry.getProviders; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a2dc8e21a394..063d3712c589 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -878,9 +878,10 @@ export const ProviderRegistryLive = Layer.effect( } const providers = yield* Ref.get(providersRef); const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); - const hasSnapshot = (candidate: ServerProvider) => - !input.fresh && candidate.workspaceSnapshots?.some((s) => s.cwd === input.cwd); - if (!provider || !provider.enabled || hasSnapshot(provider)) { + const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => + candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); + const scannedFrom = workspaceSnapshotOf(provider); + if (!provider || !provider.enabled || (!input.fresh && scannedFrom)) { return providers; } const instance = yield* instanceRegistry.getInstance(input.instanceId); @@ -909,9 +910,12 @@ export const ProviderRegistryLive = Layer.effect( : instanceRegistry.getInstance(input.instanceId).pipe( Effect.flatMap((currentInstance) => { if (currentInstance !== instance) return Ref.get(providersRef); + // Write only if the cwd's snapshot did not change during the + // scan. A session event or another scan that landed first is newer. return updateProviders((currentProviders) => currentProviders.map((candidate) => - candidate.instanceId === input.instanceId && !hasSnapshot(candidate) + candidate.instanceId === input.instanceId && + Equal.equals(workspaceSnapshotOf(candidate), scannedFrom) ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) : candidate, ),