authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-30 23:11:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-30 23:11:54-07:00
log921cb3c8bc772309ccab4c422b091d0a91497a49
tree022833348398bed1a93702256eee2351b004910f
parent7ab800a43c770b2c48a2d9306c89b6177f61ad4a
signature Signed by PGP key B5690EEEBB952194

feat(web): restart the agent session from cmd+k to load new skills and plugins (#14542)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

10 files changed, 222 insertions(+), 28 deletions(-)

apps/server/src/provider/Layers/AntigravityProvider.test.ts+12
......@@ -620,6 +620,18 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => {
620620 after.workspaceSnapshots?.find((entry) => entry.cwd === "/workspace")?.skills,
621621 ).toEqual(skills);
622622 expect((yield* harness.provider.snapshotForCwd("/workspace")).skills).toEqual(skills);
623
624 const rescanned = [
625 ...skills,
626 { name: "review", path: "/workspace/.agent/skills/review", enabled: true },
627 ];
628 yield* harness.provider.snapshotForCwd("/workspace", rescanned);
629 yield* harness.provider.onSessionStarted(started, "/workspace");
630 expect(
631 (yield* harness.provider.snapshot.getSnapshot).workspaceSnapshots?.find(
632 (entry) => entry.cwd === "/workspace",
633 )?.skills,
634 ).toEqual(rescanned);
623635 }),
624636 ),
625637 );
apps/server/src/provider/Layers/AntigravityProvider.ts+18-1
......@@ -377,7 +377,24 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func
377377 cwd: string,
378378 skills?: ServerProvider["skills"],
379379 ) {
380 if (skills) discoveredSkills.set(cwd, skills);
380 if (skills) {
381 discoveredSkills.set(cwd, skills);
382 // A rescan replaces the stored entry's skills. Session callbacks and
383 // health checks republish that entry, so it must not keep old ones.
384 yield* SubscriptionRef.update(metadata, (state) =>
385 state.draft.workspaceSnapshots?.some((entry) => entry.cwd === cwd)
386 ? {
387 ...state,
388 draft: {
389 ...state.draft,
390 workspaceSnapshots: state.draft.workspaceSnapshots.map((entry) =>
391 entry.cwd === cwd ? { ...entry, skills } : entry,
392 ),
393 },
394 }
395 : state,
396 );
397 }
381398 const snapshot = yield* getSnapshot;
382399 const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd);
383400 const resolvedSkills = skills ?? workspace?.skills ?? discoveredSkills.get(cwd) ?? [];
apps/server/src/provider/Layers/ProviderRegistry.test.ts+57-1
......@@ -1479,6 +1479,12 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test
14791479 slashCommands: [],
14801480 } as const satisfies ServerProvider;
14811481 const snapshotCalls = yield* Ref.make(0);
1482 const scopedResult = yield* Ref.make<ServerProvider>(scopedProvider);
1483 const cacheInvalidations = yield* Ref.make(0);
1484 const scanGate = yield* Ref.make<{
1485 readonly started: Deferred.Deferred<void>;
1486 readonly release: Deferred.Deferred<void>;
1487 } | null>(null);
14821488 const returnPendingSnapshot = yield* Ref.make(true);
14831489 const probeStarted = yield* Deferred.make<void>();
14841490 const releaseProbe = yield* Deferred.make<void>();
......@@ -1508,6 +1514,7 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test
15081514 applyUsageLimits: () => Effect.void,
15091515 },
15101516 snapshotForCwd,
1517 invalidateCaches: Ref.update(cacheInvalidations, (count) => count + 1),
15111518 adapter: {} as ProviderInstance["adapter"],
15121519 textGeneration: {} as ProviderInstance["textGeneration"],
15131520 });
......@@ -1517,7 +1524,13 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test
15171524 if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider;
15181525 yield* Deferred.succeed(probeStarted, undefined);
15191526 yield* Deferred.await(releaseProbe);
1520 return scopedProvider;
1527 const result = yield* Ref.get(scopedResult);
1528 const gate = yield* Ref.getAndSet(scanGate, null);
1529 if (gate) {
1530 yield* Deferred.succeed(gate.started, undefined);
1531 yield* Deferred.await(gate.release);
1532 }
1533 return result;
15211534 }),
15221535 );
15231536 const rebuiltProvider = {
......@@ -1593,6 +1606,49 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test
15931606 );
15941607 yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
15951608 assert.strictEqual(yield* Ref.get(snapshotCalls), 2);
1609 const newSkills = [
1610 ...scopedProvider.skills,
1611 { name: "added", path: "/workspace/added/SKILL.md", enabled: true },
1612 ];
1613 yield* Ref.set(scopedResult, { ...scopedProvider, skills: newSkills });
1614 yield* registry.refreshWorkspaceSnapshot({
1615 instanceId,
1616 cwd: "/workspace",
1617 fresh: true,
1618 });
1619 assert.strictEqual(yield* Ref.get(snapshotCalls), 3);
1620 assert.strictEqual(yield* Ref.get(cacheInvalidations), 1);
1621 assert.deepStrictEqual(
1622 (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills),
1623 [newSkills],
1624 );
1625
1626 // A slow fresh scan that read older files must not overwrite a
1627 // newer scan that finished first.
1628 const slowStarted = yield* Deferred.make<void>();
1629 const releaseSlow = yield* Deferred.make<void>();
1630 yield* Ref.set(scanGate, { started: slowStarted, release: releaseSlow });
1631 yield* Ref.set(scopedResult, scopedProvider);
1632 const slowScan = yield* registry
1633 .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace", fresh: true })
1634 .pipe(Effect.forkChild);
1635 yield* Deferred.await(slowStarted);
1636 const latestSkills = [
1637 ...newSkills,
1638 { name: "latest", path: "/workspace/latest/SKILL.md", enabled: true },
1639 ];
1640 yield* Ref.set(scopedResult, { ...scopedProvider, skills: latestSkills });
1641 yield* registry.refreshWorkspaceSnapshot({
1642 instanceId,
1643 cwd: "/workspace",
1644 fresh: true,
1645 });
1646 yield* Deferred.succeed(releaseSlow, undefined);
1647 yield* Fiber.join(slowScan);
1648 assert.deepStrictEqual(
1649 (yield* registry.getProviders)[0]?.workspaceSnapshots?.map((s) => s.skills),
1650 [latestSkills],
1651 );
15961652
15971653 yield* Ref.set(instancesRef, [rebuiltInstance]);
15981654 yield* PubSub.publish(registryChanges, undefined);
apps/server/src/provider/Layers/ProviderRegistry.ts+67-26
......@@ -82,6 +82,15 @@ const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean
8282
8383const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16;
8484
85function dropProviderWorkspaceSnapshot(provider: ServerProvider, cwd: string): ServerProvider {
86 return provider.workspaceSnapshots?.some((snapshot) => snapshot.cwd === cwd)
87 ? {
88 ...provider,
89 workspaceSnapshots: provider.workspaceSnapshots.filter((snapshot) => snapshot.cwd !== cwd),
90 }
91 : provider;
92}
93
8594export function upsertProviderWorkspaceSnapshot(
8695 provider: ServerProvider,
8796 cwd: string,
......@@ -835,17 +844,44 @@ export const ProviderRegistryLive = Layer.effect(
835844 return yield* Ref.get(providersRef);
836845 });
837846
847 const updateProviders = (
848 update: (providers: ReadonlyArray<ServerProvider>) => ReadonlyArray<ServerProvider>,
849 ) =>
850 Ref.modify(providersRef, (currentProviders) => {
851 const nextProviders = update(currentProviders);
852 return [[currentProviders, nextProviders] as const, nextProviders];
853 }).pipe(
854 Effect.tap(([previousProviders, nextProviders]) =>
855 haveProvidersChanged(previousProviders, nextProviders)
856 ? PubSub.publish(changesPubSub, nextProviders)
857 : Effect.void,
858 ),
859 Effect.map(([, nextProviders]) => nextProviders),
860 );
861
838862 const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: {
839863 readonly instanceId: ProviderInstanceId;
840864 readonly cwd: string;
865 readonly fresh?: boolean;
841866 }) {
867 // Fresh scans drop other instances' snapshots for this cwd first, so a
868 // composer on one of them scans again on next use, even when this
869 // instance is gone or cannot be scanned.
870 if (input.fresh) {
871 yield* updateProviders((providers) =>
872 providers.map((candidate) =>
873 candidate.instanceId === input.instanceId
874 ? candidate
875 : dropProviderWorkspaceSnapshot(candidate, input.cwd),
876 ),
877 );
878 }
842879 const providers = yield* Ref.get(providersRef);
843880 const provider = providers.find((candidate) => candidate.instanceId === input.instanceId);
844 if (
845 !provider ||
846 !provider.enabled ||
847 provider.workspaceSnapshots?.some((s) => s.cwd === input.cwd)
848 ) {
881 const workspaceSnapshotOf = (candidate: ServerProvider | undefined) =>
882 candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd);
883 const scannedFrom = workspaceSnapshotOf(provider);
884 if (!provider || !provider.enabled || (!input.fresh && scannedFrom)) {
849885 return providers;
850886 }
851887 const instance = yield* instanceRegistry.getInstance(input.instanceId);
......@@ -857,42 +893,47 @@ export const ProviderRegistryLive = Layer.effect(
857893 next.set(instance, new Set(current).add(input.cwd));
858894 return [true, next] as const;
859895 });
860 if (!claimed) return yield* Ref.get(providersRef);
861 return yield* instance.snapshotForCwd(input.cwd).pipe(
896 // A fresh scan never joins a running one, which may predate the change.
897 if (!claimed && !input.fresh) return yield* Ref.get(providersRef);
898 // Fresh scans also re-read the machine snapshot: Claude's plugin
899 // commands come from it, not from the cwd scan.
900 const refreshMachineSnapshot = input.fresh
901 ? (instance.invalidateCaches ?? Effect.void).pipe(
902 Effect.andThen(refreshInstance(input.instanceId)),
903 )
904 : Effect.void;
905 return yield* refreshMachineSnapshot.pipe(
906 Effect.andThen(instance.snapshotForCwd(input.cwd)),
862907 Effect.flatMap((scopedSnapshot) =>
863908 scopedSnapshot.status === "error"
864909 ? Ref.get(providersRef)
865910 : instanceRegistry.getInstance(input.instanceId).pipe(
866911 Effect.flatMap((currentInstance) => {
867912 if (currentInstance !== instance) return Ref.get(providersRef);
868 return Ref.modify(providersRef, (currentProviders) => {
869 const nextProviders = currentProviders.map((candidate) =>
913 // Write only if the cwd's snapshot did not change during the
914 // scan. A session event or another scan that landed first is newer.
915 return updateProviders((currentProviders) =>
916 currentProviders.map((candidate) =>
870917 candidate.instanceId === input.instanceId &&
871 !candidate.workspaceSnapshots?.some((s) => s.cwd === input.cwd)
918 Equal.equals(workspaceSnapshotOf(candidate), scannedFrom)
872919 ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot)
873920 : candidate,
874 );
875 return [[currentProviders, nextProviders] as const, nextProviders];
876 }).pipe(
877 Effect.tap(([previousProviders, nextProviders]) =>
878 haveProvidersChanged(previousProviders, nextProviders)
879 ? PubSub.publish(changesPubSub, nextProviders)
880 : Effect.void,
881921 ),
882 Effect.map(([, nextProviders]) => nextProviders),
883922 );
884923 }),
885924 ),
886925 ),
887926 Effect.ensuring(
888 Ref.update(workspaceRefreshesRef, (refreshes) => {
889 const next = new Map(refreshes);
890 const current = new Set(next.get(instance));
891 current.delete(input.cwd);
892 if (current.size) next.set(instance, current);
893 else next.delete(instance);
894 return next;
895 }),
927 claimed
928 ? Ref.update(workspaceRefreshesRef, (refreshes) => {
929 const next = new Map(refreshes);
930 const current = new Set(next.get(instance));
931 current.delete(input.cwd);
932 if (current.size) next.set(instance, current);
933 else next.delete(instance);
934 return next;
935 })
936 : Effect.void,
896937 ),
897938 );
898939 });
apps/server/src/provider/Services/ProviderRegistry.ts+7
......@@ -48,9 +48,16 @@ export interface ProviderRegistryShape {
4848 instanceId: ProviderInstanceId,
4949 ) => Effect.Effect<ReadonlyArray<ServerProvider>>;
5050
51 /**
52 * Fill the skills and slash commands snapshot for one cwd. A cwd that
53 * already has a snapshot is left alone unless `fresh` is set. A fresh scan
54 * also refreshes the instance's machine snapshot and drops other
55 * instances' snapshots for the cwd.
56 */
5157 readonly refreshWorkspaceSnapshot: (input: {
5258 readonly instanceId: ProviderInstanceId;
5359 readonly cwd: string;
60 readonly fresh?: boolean;
5461 }) => Effect.Effect<ReadonlyArray<ServerProvider>>;
5562
5663 /**
apps/server/src/ws.ts+1
......@@ -2612,6 +2612,7 @@ const makeWsRpcLayer = (
26122612 ? providerRegistry.refreshWorkspaceSnapshot({
26132613 instanceId: input.instanceId,
26142614 cwd: input.cwd,
2615 fresh: input.fresh === true,
26152616 })
26162617 : input.instanceId !== undefined
26172618 ? providerRegistry.refreshInstance(input.instanceId)
apps/web/src/components/CommandPalette.tsx+52
......@@ -53,6 +53,7 @@ import {
5353 MonitorIcon,
5454 MoonIcon,
5555 PaletteIcon,
56 RotateCcwIcon,
5657 SettingsIcon,
5758 SquarePenIcon,
5859 SunIcon,
......@@ -93,6 +94,8 @@ import { desktopLocalBackendId } from "../connection/desktopLocal";
9394import { filesystemEnvironment } from "../state/filesystem";
9495import { projectEnvironment } from "../state/projects";
9596import { useEnvironmentQuery } from "../state/query";
97import { serverEnvironment } from "../state/server";
98import { threadEnvironment } from "../state/threads";
9699import { sourceControlEnvironment } from "../state/sourceControl";
97100import { useAtomCommand } from "../state/use-atom-command";
98101import { useAtomQueryRunner } from "../state/use-atom-query-runner";
......@@ -725,6 +728,12 @@ function OpenCommandPaletteDialog(props: {
725728 const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, {
726729 reportFailure: false,
727730 });
731 const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, {
732 reportFailure: false,
733 });
734 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
735 reportFailure: false,
736 });
728737 const { environments } = useEnvironments();
729738 const desktopLocalBootstraps = useDesktopLocalBootstraps();
730739 const primaryEnvironmentId = usePrimaryEnvironmentId();
......@@ -1871,6 +1880,49 @@ function OpenCommandPaletteDialog(props: {
18711880 }
18721881 }
18731882
1883 if (activeThread !== null) {
1884 const thread = activeThread;
1885 actionItems.push({
1886 kind: "action",
1887 value: "action:restart-agent-session",
1888 searchTerms: ["restart", "reset", "reload", "agent", "session", "skills", "plugins", "mcp"],
1889 title: "Restart agent session",
1890 icon: <RotateCcwIcon className={ITEM_ICON_CLASS} />,
1891 // Stopping the provider process keeps the conversation: the next message
1892 // spawns a fresh one that resumes it and reloads skills, plugins, and MCP
1893 // servers. The fresh workspace scan updates the composer's slash menu.
1894 // Failures throw into executeItem's error toast.
1895 run: async () => {
1896 const { environmentId } = thread;
1897 if (thread.session && thread.session.status !== "stopped") {
1898 const stopped = await stopThreadSession({
1899 environmentId,
1900 input: { threadId: thread.id },
1901 });
1902 if (stopped._tag === "Failure") throw squashAtomCommandFailure(stopped);
1903 }
1904 // The server stops the process after accepting the command. A failed
1905 // stop shows in the thread.
1906 toastManager.add({
1907 type: "success",
1908 title: "Agent session will restart",
1909 description: "Your next message starts a fresh session.",
1910 });
1911 const project = projectByKey.get(`${environmentId}:${thread.projectId}`);
1912 if (!project) return;
1913 const refreshed = await refreshProviders({
1914 environmentId,
1915 input: {
1916 instanceId: thread.session?.providerInstanceId ?? thread.modelSelection.instanceId,
1917 cwd: thread.worktreePath ?? project.workspaceRoot,
1918 fresh: true,
1919 },
1920 });
1921 if (refreshed._tag === "Failure") throw squashAtomCommandFailure(refreshed);
1922 },
1923 });
1924 }
1925
18741926 actionItems.push({
18751927 kind: "action",
18761928 value: "action:open-file-picker",
docs/user/composer.md+4
......@@ -152,6 +152,10 @@ provider. On mobile, both are also available before starting a thread on
152152The slash menu also includes skills unless you turn off **Settings → General →
153153Show skills in slash menu**. Only skills enabled for the provider are listed.
154154
155After you add or change skills, plugins, or MCP servers, use **Restart agent
156session** in the command palette on web and desktop. The conversation continues,
157and your next message starts the agent again with the new setup.
158
155159Provider commands must start the message to run. T3 Code commands such as
156160`/model` and `/plan`, and skill mentions, work on any line.
157161
packages/client-runtime/src/state/server.ts+1
......@@ -1103,6 +1103,7 @@ export function createServerEnvironmentAtoms<R, E>(
11031103 environmentId,
11041104 input.instanceId ?? null,
11051105 input.cwd ?? null,
1106 input.fresh ?? false,
11061107 input.refreshModels ?? false,
11071108 ]),
11081109 },
packages/contracts/src/rpc.ts+3
......@@ -494,6 +494,9 @@ const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders,
494494 */
495495 instanceId: Schema.optional(ProviderInstanceId),
496496 cwd: Schema.optional(TrimmedNonEmptyString),
497 /** With `instanceId` and `cwd`: rescan the workspace's skills and slash
498 * commands even when a snapshot for that cwd already exists. */
499 fresh: Schema.optional(Schema.Boolean),
497500 /** Explicit user request: bypass T3-owned caches and rediscover models.
498501 * Background status refreshes must not open agent sessions. */
499502 refreshModels: Schema.optional(Schema.Boolean),