| author | |
| committer | |
| log | 921cb3c8bc772309ccab4c422b091d0a91497a49 |
| tree | 022833348398bed1a93702256eee2351b004910f |
| parent | 7ab800a43c770b2c48a2d9306c89b6177f61ad4a |
| signature | Signed by PGP key B5690EEEBB952194 |
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) => { | ... | @@ -620,6 +620,18 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => { |
| 620 | after.workspaceSnapshots?.find((entry) => entry.cwd === "/workspace")?.skills, | 620 | after.workspaceSnapshots?.find((entry) => entry.cwd === "/workspace")?.skills, |
| 621 | ).toEqual(skills); | 621 | ).toEqual(skills); |
| 622 | expect((yield* harness.provider.snapshotForCwd("/workspace")).skills).toEqual(skills); | 622 | 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); | ||
| 623 | }), | 635 | }), |
| 624 | ), | 636 | ), |
| 625 | ); | 637 | ); |
apps/server/src/provider/Layers/AntigravityProvider.ts+18-1| ... | @@ -377,7 +377,24 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func | ... | @@ -377,7 +377,24 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 377 | cwd: string, | 377 | cwd: string, |
| 378 | skills?: ServerProvider["skills"], | 378 | skills?: ServerProvider["skills"], |
| 379 | ) { | 379 | ) { |
| 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 | } | ||
| 381 | const snapshot = yield* getSnapshot; | 398 | const snapshot = yield* getSnapshot; |
| 382 | const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd); | 399 | const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd); |
| 383 | const resolvedSkills = skills ?? workspace?.skills ?? discoveredSkills.get(cwd) ?? []; | 400 | 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 | ... | @@ -1479,6 +1479,12 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test |
| 1479 | slashCommands: [], | 1479 | slashCommands: [], |
| 1480 | } as const satisfies ServerProvider; | 1480 | } as const satisfies ServerProvider; |
| 1481 | const snapshotCalls = yield* Ref.make(0); | 1481 | 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); | ||
| 1482 | const returnPendingSnapshot = yield* Ref.make(true); | 1488 | const returnPendingSnapshot = yield* Ref.make(true); |
| 1483 | const probeStarted = yield* Deferred.make<void>(); | 1489 | const probeStarted = yield* Deferred.make<void>(); |
| 1484 | const releaseProbe = yield* Deferred.make<void>(); | 1490 | const releaseProbe = yield* Deferred.make<void>(); |
| ... | @@ -1508,6 +1514,7 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test | ... | @@ -1508,6 +1514,7 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test |
| 1508 | applyUsageLimits: () => Effect.void, | 1514 | applyUsageLimits: () => Effect.void, |
| 1509 | }, | 1515 | }, |
| 1510 | snapshotForCwd, | 1516 | snapshotForCwd, |
| 1517 | invalidateCaches: Ref.update(cacheInvalidations, (count) => count + 1), | ||
| 1511 | adapter: {} as ProviderInstance["adapter"], | 1518 | adapter: {} as ProviderInstance["adapter"], |
| 1512 | textGeneration: {} as ProviderInstance["textGeneration"], | 1519 | textGeneration: {} as ProviderInstance["textGeneration"], |
| 1513 | }); | 1520 | }); |
| ... | @@ -1517,7 +1524,13 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test | ... | @@ -1517,7 +1524,13 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test |
| 1517 | if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider; | 1524 | if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider; |
| 1518 | yield* Deferred.succeed(probeStarted, undefined); | 1525 | yield* Deferred.succeed(probeStarted, undefined); |
| 1519 | yield* Deferred.await(releaseProbe); | 1526 | 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; | ||
| 1521 | }), | 1534 | }), |
| 1522 | ); | 1535 | ); |
| 1523 | const rebuiltProvider = { | 1536 | const rebuiltProvider = { |
| ... | @@ -1593,6 +1606,49 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test | ... | @@ -1593,6 +1606,49 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test |
| 1593 | ); | 1606 | ); |
| 1594 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); | 1607 | yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); |
| 1595 | assert.strictEqual(yield* Ref.get(snapshotCalls), 2); | 1608 | 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 | ); | ||
| 1596 | 1652 | ||
| 1597 | yield* Ref.set(instancesRef, [rebuiltInstance]); | 1653 | yield* Ref.set(instancesRef, [rebuiltInstance]); |
| 1598 | yield* PubSub.publish(registryChanges, undefined); | 1654 | 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 | ... | @@ -82,6 +82,15 @@ const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean |
| 82 | 82 | ||
| 83 | const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16; | 83 | const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16; |
| 84 | 84 | ||
| 85 | function 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 | |||
| 85 | export function upsertProviderWorkspaceSnapshot( | 94 | export function upsertProviderWorkspaceSnapshot( |
| 86 | provider: ServerProvider, | 95 | provider: ServerProvider, |
| 87 | cwd: string, | 96 | cwd: string, |
| ... | @@ -835,17 +844,44 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -835,17 +844,44 @@ export const ProviderRegistryLive = Layer.effect( |
| 835 | return yield* Ref.get(providersRef); | 844 | return yield* Ref.get(providersRef); |
| 836 | }); | 845 | }); |
| 837 | 846 | ||
| 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 | |||
| 838 | const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { | 862 | const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { |
| 839 | readonly instanceId: ProviderInstanceId; | 863 | readonly instanceId: ProviderInstanceId; |
| 840 | readonly cwd: string; | 864 | readonly cwd: string; |
| 865 | readonly fresh?: boolean; | ||
| 841 | }) { | 866 | }) { |
| 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 | } | ||
| 842 | const providers = yield* Ref.get(providersRef); | 879 | const providers = yield* Ref.get(providersRef); |
| 843 | const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); | 880 | const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); |
| 844 | if ( | 881 | const workspaceSnapshotOf = (candidate: ServerProvider | undefined) => |
| 845 | !provider || | 882 | candidate?.workspaceSnapshots?.find((s) => s.cwd === input.cwd); |
| 846 | !provider.enabled || | 883 | const scannedFrom = workspaceSnapshotOf(provider); |
| 847 | provider.workspaceSnapshots?.some((s) => s.cwd === input.cwd) | 884 | if (!provider || !provider.enabled || (!input.fresh && scannedFrom)) { |
| 848 | ) { | ||
| 849 | return providers; | 885 | return providers; |
| 850 | } | 886 | } |
| 851 | const instance = yield* instanceRegistry.getInstance(input.instanceId); | 887 | const instance = yield* instanceRegistry.getInstance(input.instanceId); |
| ... | @@ -857,42 +893,47 @@ export const ProviderRegistryLive = Layer.effect( | ... | @@ -857,42 +893,47 @@ export const ProviderRegistryLive = Layer.effect( |
| 857 | next.set(instance, new Set(current).add(input.cwd)); | 893 | next.set(instance, new Set(current).add(input.cwd)); |
| 858 | return [true, next] as const; | 894 | return [true, next] as const; |
| 859 | }); | 895 | }); |
| 860 | if (!claimed) return yield* Ref.get(providersRef); | 896 | // A fresh scan never joins a running one, which may predate the change. |
| 861 | return yield* instance.snapshotForCwd(input.cwd).pipe( | 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)), | ||
| 862 | Effect.flatMap((scopedSnapshot) => | 907 | Effect.flatMap((scopedSnapshot) => |
| 863 | scopedSnapshot.status === "error" | 908 | scopedSnapshot.status === "error" |
| 864 | ? Ref.get(providersRef) | 909 | ? Ref.get(providersRef) |
| 865 | : instanceRegistry.getInstance(input.instanceId).pipe( | 910 | : instanceRegistry.getInstance(input.instanceId).pipe( |
| 866 | Effect.flatMap((currentInstance) => { | 911 | Effect.flatMap((currentInstance) => { |
| 867 | if (currentInstance !== instance) return Ref.get(providersRef); | 912 | if (currentInstance !== instance) return Ref.get(providersRef); |
| 868 | return Ref.modify(providersRef, (currentProviders) => { | 913 | // Write only if the cwd's snapshot did not change during the |
| 869 | const nextProviders = currentProviders.map((candidate) => | 914 | // scan. A session event or another scan that landed first is newer. |
| 915 | return updateProviders((currentProviders) => | ||
| 916 | currentProviders.map((candidate) => | ||
| 870 | candidate.instanceId === input.instanceId && | 917 | candidate.instanceId === input.instanceId && |
| 871 | !candidate.workspaceSnapshots?.some((s) => s.cwd === input.cwd) | 918 | Equal.equals(workspaceSnapshotOf(candidate), scannedFrom) |
| 872 | ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) | 919 | ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) |
| 873 | : candidate, | 920 | : 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, | ||
| 881 | ), | 921 | ), |
| 882 | Effect.map(([, nextProviders]) => nextProviders), | ||
| 883 | ); | 922 | ); |
| 884 | }), | 923 | }), |
| 885 | ), | 924 | ), |
| 886 | ), | 925 | ), |
| 887 | Effect.ensuring( | 926 | Effect.ensuring( |
| 888 | Ref.update(workspaceRefreshesRef, (refreshes) => { | 927 | claimed |
| 889 | const next = new Map(refreshes); | 928 | ? Ref.update(workspaceRefreshesRef, (refreshes) => { |
| 890 | const current = new Set(next.get(instance)); | 929 | const next = new Map(refreshes); |
| 891 | current.delete(input.cwd); | 930 | const current = new Set(next.get(instance)); |
| 892 | if (current.size) next.set(instance, current); | 931 | current.delete(input.cwd); |
| 893 | else next.delete(instance); | 932 | if (current.size) next.set(instance, current); |
| 894 | return next; | 933 | else next.delete(instance); |
| 895 | }), | 934 | return next; |
| 935 | }) | ||
| 936 | : Effect.void, | ||
| 896 | ), | 937 | ), |
| 897 | ); | 938 | ); |
| 898 | }); | 939 | }); |
apps/server/src/provider/Services/ProviderRegistry.ts+7| ... | @@ -48,9 +48,16 @@ export interface ProviderRegistryShape { | ... | @@ -48,9 +48,16 @@ export interface ProviderRegistryShape { |
| 48 | instanceId: ProviderInstanceId, | 48 | instanceId: ProviderInstanceId, |
| 49 | ) => Effect.Effect<ReadonlyArray<ServerProvider>>; | 49 | ) => Effect.Effect<ReadonlyArray<ServerProvider>>; |
| 50 | 50 | ||
| 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 | */ | ||
| 51 | readonly refreshWorkspaceSnapshot: (input: { | 57 | readonly refreshWorkspaceSnapshot: (input: { |
| 52 | readonly instanceId: ProviderInstanceId; | 58 | readonly instanceId: ProviderInstanceId; |
| 53 | readonly cwd: string; | 59 | readonly cwd: string; |
| 60 | readonly fresh?: boolean; | ||
| 54 | }) => Effect.Effect<ReadonlyArray<ServerProvider>>; | 61 | }) => Effect.Effect<ReadonlyArray<ServerProvider>>; |
| 55 | 62 | ||
| 56 | /** | 63 | /** |
apps/server/src/ws.ts+1| ... | @@ -2612,6 +2612,7 @@ const makeWsRpcLayer = ( | ... | @@ -2612,6 +2612,7 @@ const makeWsRpcLayer = ( |
| 2612 | ? providerRegistry.refreshWorkspaceSnapshot({ | 2612 | ? providerRegistry.refreshWorkspaceSnapshot({ |
| 2613 | instanceId: input.instanceId, | 2613 | instanceId: input.instanceId, |
| 2614 | cwd: input.cwd, | 2614 | cwd: input.cwd, |
| 2615 | fresh: input.fresh === true, | ||
| 2615 | }) | 2616 | }) |
| 2616 | : input.instanceId !== undefined | 2617 | : input.instanceId !== undefined |
| 2617 | ? providerRegistry.refreshInstance(input.instanceId) | 2618 | ? providerRegistry.refreshInstance(input.instanceId) |
apps/web/src/components/CommandPalette.tsx+52| ... | @@ -53,6 +53,7 @@ import { | ... | @@ -53,6 +53,7 @@ import { |
| 53 | MonitorIcon, | 53 | MonitorIcon, |
| 54 | MoonIcon, | 54 | MoonIcon, |
| 55 | PaletteIcon, | 55 | PaletteIcon, |
| 56 | RotateCcwIcon, | ||
| 56 | SettingsIcon, | 57 | SettingsIcon, |
| 57 | SquarePenIcon, | 58 | SquarePenIcon, |
| 58 | SunIcon, | 59 | SunIcon, |
| ... | @@ -93,6 +94,8 @@ import { desktopLocalBackendId } from "../connection/desktopLocal"; | ... | @@ -93,6 +94,8 @@ import { desktopLocalBackendId } from "../connection/desktopLocal"; |
| 93 | import { filesystemEnvironment } from "../state/filesystem"; | 94 | import { filesystemEnvironment } from "../state/filesystem"; |
| 94 | import { projectEnvironment } from "../state/projects"; | 95 | import { projectEnvironment } from "../state/projects"; |
| 95 | import { useEnvironmentQuery } from "../state/query"; | 96 | import { useEnvironmentQuery } from "../state/query"; |
| 97 | import { serverEnvironment } from "../state/server"; | ||
| 98 | import { threadEnvironment } from "../state/threads"; | ||
| 96 | import { sourceControlEnvironment } from "../state/sourceControl"; | 99 | import { sourceControlEnvironment } from "../state/sourceControl"; |
| 97 | import { useAtomCommand } from "../state/use-atom-command"; | 100 | import { useAtomCommand } from "../state/use-atom-command"; |
| 98 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | 101 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| ... | @@ -725,6 +728,12 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -725,6 +728,12 @@ function OpenCommandPaletteDialog(props: { |
| 725 | const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { | 728 | const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { |
| 726 | reportFailure: false, | 729 | reportFailure: false, |
| 727 | }); | 730 | }); |
| 731 | const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, { | ||
| 732 | reportFailure: false, | ||
| 733 | }); | ||
| 734 | const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { | ||
| 735 | reportFailure: false, | ||
| 736 | }); | ||
| 728 | const { environments } = useEnvironments(); | 737 | const { environments } = useEnvironments(); |
| 729 | const desktopLocalBootstraps = useDesktopLocalBootstraps(); | 738 | const desktopLocalBootstraps = useDesktopLocalBootstraps(); |
| 730 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 739 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| ... | @@ -1871,6 +1880,49 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1871,6 +1880,49 @@ function OpenCommandPaletteDialog(props: { |
| 1871 | } | 1880 | } |
| 1872 | } | 1881 | } |
| 1873 | 1882 | ||
| 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 | |||
| 1874 | actionItems.push({ | 1926 | actionItems.push({ |
| 1875 | kind: "action", | 1927 | kind: "action", |
| 1876 | value: "action:open-file-picker", | 1928 | 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 | ... | @@ -152,6 +152,10 @@ provider. On mobile, both are also available before starting a thread on |
| 152 | The slash menu also includes skills unless you turn off **Settings → General → | 152 | The slash menu also includes skills unless you turn off **Settings → General → |
| 153 | Show skills in slash menu**. Only skills enabled for the provider are listed. | 153 | Show skills in slash menu**. Only skills enabled for the provider are listed. |
| 154 | 154 | ||
| 155 | After you add or change skills, plugins, or MCP servers, use **Restart agent | ||
| 156 | session** in the command palette on web and desktop. The conversation continues, | ||
| 157 | and your next message starts the agent again with the new setup. | ||
| 158 | |||
| 155 | Provider commands must start the message to run. T3 Code commands such as | 159 | Provider commands must start the message to run. T3 Code commands such as |
| 156 | `/model` and `/plan`, and skill mentions, work on any line. | 160 | `/model` and `/plan`, and skill mentions, work on any line. |
| 157 | 161 |
packages/client-runtime/src/state/server.ts+1| ... | @@ -1103,6 +1103,7 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -1103,6 +1103,7 @@ export function createServerEnvironmentAtoms<R, E>( |
| 1103 | environmentId, | 1103 | environmentId, |
| 1104 | input.instanceId ?? null, | 1104 | input.instanceId ?? null, |
| 1105 | input.cwd ?? null, | 1105 | input.cwd ?? null, |
| 1106 | input.fresh ?? false, | ||
| 1106 | input.refreshModels ?? false, | 1107 | input.refreshModels ?? false, |
| 1107 | ]), | 1108 | ]), |
| 1108 | }, | 1109 | }, |
packages/contracts/src/rpc.ts+3| ... | @@ -494,6 +494,9 @@ const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, | ... | @@ -494,6 +494,9 @@ const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, |
| 494 | */ | 494 | */ |
| 495 | instanceId: Schema.optional(ProviderInstanceId), | 495 | instanceId: Schema.optional(ProviderInstanceId), |
| 496 | cwd: Schema.optional(TrimmedNonEmptyString), | 496 | 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), | ||
| 497 | /** Explicit user request: bypass T3-owned caches and rediscover models. | 500 | /** Explicit user request: bypass T3-owned caches and rediscover models. |
| 498 | * Background status refreshes must not open agent sessions. */ | 501 | * Background status refreshes must not open agent sessions. */ |
| 499 | refreshModels: Schema.optional(Schema.Boolean), | 502 | refreshModels: Schema.optional(Schema.Boolean), |